diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md
index 1f3cfd10..17608be2 100644
--- a/docs/architecture/code-structure.md
+++ b/docs/architecture/code-structure.md
@@ -195,6 +195,9 @@ src/AcDream.App/
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration
│ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path
│ ├── LiveEntityNetworkUpdateController.cs # Position/Vector/State/Motion App integration
+│ ├── LiveEntityInboundAuthorityGate.cs # exact per-channel timestamp/version admission
+│ ├── LiveEntityMotionRuntimeController.cs # shared host/setup/MoveTo/Sticky runtime policy
+│ ├── DeferredLiveEntityMotionRuntimeBindings.cs # fail-fast construction-order bridge
│ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate
│ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index dce12142..38ffd9a9 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -86,7 +86,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
-| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
+| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it). The companion `MoveToCancelled` seam only withdraws the waiting client presentation/action. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Rendering/GameWindow.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract; cancellation/error only clears retained intent. | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
@@ -144,7 +144,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 |
-| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 |
+| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 |
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
| AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures |
@@ -181,14 +181,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification |
| AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.App/Input/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner |
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
-| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks), moving to `LiveEntityNetworkUpdateController` in Slice 4 | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. |
+| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| 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 + 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/Rendering/GameWindow.cs` (`OnLivePositionUpdated` 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-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-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 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
@@ -215,8 +215,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
-| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Rendering/GameWindow.cs` (`RouteSameGenerationCreateObject`); moving to `LiveEntityNetworkUpdateController` in Slice 4 | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
-| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); App same-generation route in `GameWindow.cs`, moving to `LiveEntityNetworkUpdateController` in Slice 4 | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` |
+| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
+| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` |
## 4. Temporary stopgap (TS) — 34 active rows + 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)
@@ -250,13 +250,13 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
| 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/Rendering/GameWindow.cs` (`TickAnimations` 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/Rendering/GameWindow.cs` (`OnLivePositionUpdated` 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-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
+| ~~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-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupCylinder`); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
| ~~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` |
-| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Rendering/GameWindow.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
+| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum |
| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |
diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.App/Input/LocalPlayerOutboundController.cs
index daff2089..a0b8c6a1 100644
--- a/src/AcDream.App/Input/LocalPlayerOutboundController.cs
+++ b/src/AcDream.App/Input/LocalPlayerOutboundController.cs
@@ -121,6 +121,47 @@ public sealed class LocalPlayerOutboundController
controller.SimTimeSeconds);
}
+ ///
+ /// Retail ForcePosition acknowledgement sent immediately after the local
+ /// body is blipped. Unlike the periodic post-network sender, this bypasses
+ /// the elapsed/difference predicate but still requires a valid grounded
+ /// canonical frame.
+ ///
+ internal void SendImmediatePosition(
+ WorldSession? session,
+ PlayerMovementController? controller)
+ {
+ if (session is null
+ || controller is null
+ || !controller.CanSendPositionEvent)
+ {
+ return;
+ }
+
+ if (!controller.TryGetOutboundPosition(out Position outboundPosition))
+ return;
+ uint cellId = outboundPosition.ObjCellId;
+ Vector3 position = outboundPosition.Frame.Origin;
+ Quaternion rotation = outboundPosition.Frame.Orientation;
+
+ uint sequence = session.NextGameActionSequence();
+ byte[] body = AutonomousPosition.Build(
+ gameActionSequence: sequence,
+ cellId: cellId,
+ position: position,
+ rotation: rotation,
+ instanceSequence: session.InstanceSequence,
+ serverControlSequence: session.ServerControlSequence,
+ teleportSequence: session.TeleportSequence,
+ forcePositionSequence: session.ForcePositionSequence,
+ lastContact: 1);
+ session.SendGameAction(body);
+ controller.NotePositionSent(
+ outboundPosition,
+ controller.ContactPlane,
+ controller.SimTimeSeconds);
+ }
+
public bool TrySendMovement(
WorldSession? session,
PlayerMovementController? controller,
diff --git a/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs b/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs
new file mode 100644
index 00000000..6a2cb41c
--- /dev/null
+++ b/src/AcDream.App/Physics/DeferredLiveEntityMotionRuntimeBindings.cs
@@ -0,0 +1,64 @@
+using AcDream.App.Physics;
+using AcDream.App.Rendering;
+using AcDream.Core.Net;
+using AcDream.Core.Physics.Motion;
+using AcDream.Core.World;
+
+namespace AcDream.App.Physics;
+
+///
+/// Narrow reverse seam for collaborators constructed before the live network
+/// owner. It carries no state or identity: every call resolves the one bound
+/// update-thread owner, and use before bind fails immediately.
+///
+internal interface ILiveEntityMotionRuntimeBindings
+{
+ (float Radius, float Height) GetSetupCylinder(uint serverGuid, WorldEntity entity);
+ bool RouteServerMoveTo(
+ MovementManager movement,
+ uint cellId,
+ WorldSession.EntityMotionUpdate update);
+ void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid);
+ void ClearTargetForHiddenEntity(uint serverGuid);
+ IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid);
+}
+
+internal sealed class DeferredLiveEntityMotionRuntimeBindings
+ : ILiveEntityMotionRuntimeBindings
+{
+ private ILiveEntityMotionRuntimeBindings? _target;
+
+ public void Bind(ILiveEntityMotionRuntimeBindings target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ if (_target is not null)
+ {
+ throw new InvalidOperationException(
+ "Live entity motion runtime bindings are already bound.");
+ }
+ _target = target;
+ }
+
+ private ILiveEntityMotionRuntimeBindings Target =>
+ _target ?? throw new InvalidOperationException(
+ "Live entity motion runtime bindings were used before binding.");
+
+ public (float Radius, float Height) GetSetupCylinder(
+ uint serverGuid,
+ WorldEntity entity) => Target.GetSetupCylinder(serverGuid, entity);
+
+ public bool RouteServerMoveTo(
+ MovementManager movement,
+ uint cellId,
+ WorldSession.EntityMotionUpdate update) =>
+ Target.RouteServerMoveTo(movement, cellId, update);
+
+ public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) =>
+ Target.StickToObjectFromWire(host, targetGuid);
+
+ public void ClearTargetForHiddenEntity(uint serverGuid) =>
+ Target.ClearTargetForHiddenEntity(serverGuid);
+
+ public IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid) =>
+ Target.ResolvePhysicsHost(serverGuid);
+}
diff --git a/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs b/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs
new file mode 100644
index 00000000..96916677
--- /dev/null
+++ b/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs
@@ -0,0 +1,209 @@
+using System.Numerics;
+using AcDream.App.World;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Physics;
+
+internal readonly record struct AcceptedMotionNetworkUpdate(
+ WorldSession.EntityMotionUpdate Update,
+ LiveEntityRecord Record,
+ AcceptedPhysicsTimestamps Timestamps,
+ ulong MovementAuthorityVersion,
+ ulong VelocityAuthorityVersion);
+
+internal readonly record struct AcceptedVectorNetworkUpdate(
+ VectorUpdate.Parsed Update,
+ LiveEntityRecord Record,
+ ulong VectorAuthorityVersion,
+ ulong VelocityAuthorityVersion);
+
+internal readonly record struct AcceptedStateNetworkUpdate(
+ SetState.Parsed Update,
+ LiveEntityRecord Record,
+ ulong StateAuthorityVersion);
+
+internal readonly record struct AcceptedPositionNetworkUpdate(
+ WorldSession.EntityPositionUpdate Update,
+ LiveEntityRecord Record,
+ WorldSession.EntitySpawn Spawn,
+ AcceptedPhysicsTimestamps Timestamps,
+ PositionTimestampDisposition TimestampDisposition,
+ ulong PositionAuthorityVersion,
+ ulong VelocityAuthorityVersion);
+
+///
+/// Owns the one retail timestamp/authority admission edge for live inbound
+/// Movement, Vector, State, and Position packets. The accepted context pins
+/// the exact record and channel versions before any re-entrant App callback
+/// can run; channel presenters must revalidate those captures after callbacks.
+///
+internal sealed class LiveEntityInboundAuthorityGate
+{
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly Action _publishTimestamps;
+
+ public LiveEntityInboundAuthorityGate(
+ LiveEntityRuntime liveEntities,
+ Action publishTimestamps)
+ {
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _publishTimestamps = publishTimestamps
+ ?? throw new ArgumentNullException(nameof(publishTimestamps));
+ }
+
+ internal uint? LastLivePlayerLandblockId { get; private set; }
+
+ internal void ResetSessionState() => LastLivePlayerLandblockId = null;
+
+ internal bool TryAcceptMotion(
+ WorldSession.EntityMotionUpdate update,
+ bool retainPayload,
+ out AcceptedMotionNetworkUpdate accepted,
+ out bool timestampAccepted)
+ {
+ accepted = default;
+ timestampAccepted = _liveEntities.TryApplyMotion(
+ update,
+ retainPayload,
+ out _,
+ out AcceptedPhysicsTimestamps timestamps);
+ if (!timestampAccepted)
+ return false;
+
+ LiveEntityRecord? record = null;
+ ulong movementAuthorityVersion = 0;
+ ulong velocityAuthorityVersion = 0;
+ if (retainPayload)
+ {
+ if (!_liveEntities.TryGetRecord(update.Guid, out record))
+ return false;
+ movementAuthorityVersion = record.MovementAuthorityVersion;
+ velocityAuthorityVersion = record.VelocityAuthorityVersion;
+ }
+
+ _publishTimestamps(update.Guid, timestamps);
+ if (!retainPayload)
+ return false;
+ if (!_liveEntities.IsCurrentMovementAuthority(
+ record!,
+ movementAuthorityVersion)
+ || !_liveEntities.IsCurrentVelocityAuthority(
+ record!,
+ velocityAuthorityVersion))
+ {
+ return false;
+ }
+
+ accepted = new AcceptedMotionNetworkUpdate(
+ update,
+ record!,
+ timestamps,
+ movementAuthorityVersion,
+ velocityAuthorityVersion);
+ return true;
+ }
+
+ internal bool TryAcceptVector(
+ VectorUpdate.Parsed update,
+ bool payloadIsValid,
+ out AcceptedVectorNetworkUpdate accepted)
+ {
+ accepted = default;
+ if (!payloadIsValid
+ || !_liveEntities.TryApplyVector(update, out _)
+ || !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
+ {
+ return false;
+ }
+
+ accepted = new AcceptedVectorNetworkUpdate(
+ update,
+ record,
+ record.VectorAuthorityVersion,
+ record.VelocityAuthorityVersion);
+ return true;
+ }
+
+ internal bool TryAcceptState(
+ SetState.Parsed update,
+ out AcceptedStateNetworkUpdate accepted)
+ {
+ accepted = default;
+ if (!_liveEntities.TryApplyState(update, out _, out _)
+ || !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
+ {
+ return false;
+ }
+
+ accepted = new AcceptedStateNetworkUpdate(
+ update,
+ record,
+ record.StateAuthorityVersion);
+ return true;
+ }
+
+ internal bool TryAcceptPosition(
+ WorldSession.EntityPositionUpdate update,
+ uint localPlayerGuid,
+ Quaternion? forcePositionRotation,
+ Vector3? currentLocalVelocity,
+ bool payloadIsValid,
+ out AcceptedPositionNetworkUpdate accepted)
+ {
+ accepted = default;
+ if (!payloadIsValid)
+ return false;
+
+ bool isLocalPlayer = update.Guid == localPlayerGuid;
+ bool known = _liveEntities.TryApplyPosition(
+ update,
+ isLocalPlayer,
+ isLocalPlayer ? forcePositionRotation : null,
+ isLocalPlayer ? currentLocalVelocity : null,
+ out PositionTimestampDisposition disposition,
+ out WorldSession.EntitySpawn spawn,
+ out AcceptedPhysicsTimestamps timestamps);
+ if (!known)
+ {
+ if (isLocalPlayer)
+ LastLivePlayerLandblockId = update.Position.LandblockId;
+ return false;
+ }
+
+ LiveEntityRecord? record = null;
+ ulong positionAuthorityVersion = 0;
+ ulong velocityAuthorityVersion = 0;
+ if (disposition is not PositionTimestampDisposition.Rejected)
+ {
+ if (!_liveEntities.TryGetRecord(update.Guid, out record))
+ return false;
+ positionAuthorityVersion = record.PositionAuthorityVersion;
+ velocityAuthorityVersion = record.VelocityAuthorityVersion;
+ }
+
+ _publishTimestamps(update.Guid, timestamps);
+ if (disposition is PositionTimestampDisposition.Rejected)
+ return false;
+ if (!_liveEntities.IsCurrentPositionAuthority(
+ record!,
+ positionAuthorityVersion))
+ {
+ return false;
+ }
+
+ accepted = new AcceptedPositionNetworkUpdate(
+ update,
+ record!,
+ spawn,
+ timestamps,
+ disposition,
+ positionAuthorityVersion,
+ velocityAuthorityVersion);
+ return true;
+ }
+
+ internal void ObserveAcceptedLocalPosition(uint landblockId) =>
+ LastLivePlayerLandblockId = landblockId;
+}
diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
new file mode 100644
index 00000000..55195a3f
--- /dev/null
+++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
@@ -0,0 +1,488 @@
+using AcDream.App.Interaction;
+using AcDream.App.Rendering;
+using AcDream.App.World;
+using AcDream.Core.Physics;
+using AcDream.Core.Selection;
+using AcDream.Core.World;
+
+namespace AcDream.App.Physics;
+
+///
+/// Focused owner of reusable live CPhysicsObj-style host lookup, remote motion
+/// binding, server MoveTo/sticky routing, setup-cylinder dimensions, and
+/// Hidden target invalidation. It is independent of packet orchestration so
+/// renderer, selection, and movement collaborators share one one-way service.
+///
+internal sealed class LiveEntityMotionRuntimeController
+ : ILiveEntityMotionRuntimeBindings
+{
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly PhysicsDataCache _physicsDataCache;
+ private readonly Func _selectionInteractions;
+ private readonly SelectionState _selection;
+ private readonly LiveWorldOriginState _origin;
+
+ private IReadOnlyDictionary _entitiesByServerGuid =>
+ _liveEntities.MaterializedWorldEntities;
+ private IReadOnlyDictionary _visibleEntitiesByServerGuid =>
+ _liveEntities.WorldEntities;
+
+ public LiveEntityMotionRuntimeController(
+ LiveEntityRuntime liveEntities,
+ PhysicsDataCache physicsDataCache,
+ Func selectionInteractions,
+ SelectionState selection,
+ LiveWorldOriginState origin)
+ {
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
+ _selectionInteractions = selectionInteractions ?? throw new ArgumentNullException(nameof(selectionInteractions));
+ _selection = selection ?? throw new ArgumentNullException(nameof(selection));
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ }
+ internal AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
+ RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid)
+ {
+ AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer;
+ if (sequencer is not null && rm.Sink is null)
+ {
+ rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(sequencer);
+ rm.Motion.DefaultSink = rm.Sink;
+ }
+ // #174 (2026-07-05): the RemoveLinkAnimations seam is retail
+ // CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
+ // CPartArray::HandleEnterWorld 0x00517d70 →
+ // MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the
+ // sequence's link animations AND drain pending_animations
+ // completely (each pop fires MotionDone → CMotionInterp pops its
+ // pending_motions node in lockstep). The previous binding was the
+ // bare sequence strip — every LeaveGround (jump) stripped the
+ // animations that queued manager nodes were counting down on,
+ // orphaning them (NumAnims>0, anims gone) and permanently damming
+ // BOTH queues: MotionsPending() never drained again, so every
+ // armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
+ // starved — the "door only works on a fresh session" bug.
+ if (sequencer is not null)
+ {
+ rm.Motion.RemoveLinkAnimations = () => sequencer.Manager.HandleEnterWorld();
+ rm.Motion.InitializeMotionTables = () => sequencer.Manager.InitializeState();
+ // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
+ // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
+ rm.Motion.CheckForCompletedMotions = sequencer.Manager.CheckForCompletedMotions;
+ }
+
+ // Host/MoveTo ownership does not depend on a render animation. AP-77
+ // deliberately supplies CMotionInterp's headless body fallback when a
+ // live object has no PartArray sink, so build the manager once anyway.
+ if (rm.Host is not null)
+ return rm.Sink;
+ if (_liveEntities is not { } liveEntities
+ || !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord)
+ || !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm))
+ {
+ return rm.Sink;
+ }
+ // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
+ // wiring (the conformance-tested reference). Positions are WORLD
+ // space on both sides (getPosition + the MovementStruct positions
+ // the UM router builds) — one consistent space, so the manager's
+ // distance/heading math matches the harness exactly.
+ // R5-V5: the construction is now the MovementManager facade's
+ // MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj
+ // backpointers retail's MakeMoveToManager 0x00524000 constructs
+ // from); MakeMoveToManager() below invokes it once, preserving the
+ // pre-facade eager timing.
+ var rmT = rm;
+ var mtBody = rm.Body;
+ // R5-V3 (#171): real setup cylsphere radii — retail CPartArray::
+ // GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve
+ // (a dictionary hit) so the read never races the spawn path's
+ // CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math
+ // (own side) and StickyManager::adjust_offset's own-radius gap term.
+ // Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with
+ // R5-V3").
+ if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEntity))
+ return rm.Sink;
+ // R5-V2: forward-declared so the MoveToManager's target seams route
+ // into the entity's TargetManager (retail CPhysicsObj::set_target →
+ // TargetManager::SetTarget). Assigned right after the manager is built.
+ EntityPhysicsHost host = null!;
+ double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ rm.Movement.MoveToFactory = () =>
+ {
+ var mtm = new AcDream.Core.Physics.Motion.MoveToManager(
+ rm.Motion,
+ stopCompletely: () => rmT.Movement.PerformMovement(
+ new AcDream.Core.Physics.MovementStruct
+ {
+ Type = AcDream.Core.Physics.MovementType.StopCompletely,
+ }),
+ getPosition: () => new AcDream.Core.Physics.Position(
+ rmT.CellId, mtBody.Position, mtBody.Orientation),
+ getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
+ mtBody.Orientation),
+ setHeading: (h, _) => mtBody.Orientation =
+ AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
+ getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
+ getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height,
+ contact: () => mtBody.OnWalkable,
+ isInterpolating: () => rmT.Interp.IsActive,
+ getVelocity: () => mtBody.Velocity,
+ getSelfId: () => serverGuid,
+ // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum
+ // → the entity's TargetManager (replaces the AP-79 TrackedTarget*
+ // poll). The manager passes (0, top_level_id, 0.5, 0.0).
+ setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q),
+ clearTarget: () => host.ClearTarget(),
+ getTargetQuantum: () => host.TargetManager.GetTargetQuantum(),
+ setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q),
+ // R4-V5: real clock (same epoch-seconds base the per-remote tick uses).
+ curTime: NowSeconds);
+ // R5-V3 (#171, retires TS-39): bind the sticky seams to the host's
+ // PositionManager (host is constructed before MakeMoveToManager
+ // invokes this factory) —
+ // * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo
+ // (retail MoveToManager::BeginNextNode @0x00529d3a);
+ // * PerformMovement's head unstick: unstick_from_object →
+ // PositionManager::UnStick (MoveToManager.PerformMovement:414).
+ mtm.StickTo = (tlid, radius, height) =>
+ host.PositionManager.StickTo(tlid, radius, height);
+ mtm.Unstick = host.PositionManager.UnStick;
+ return mtm;
+ };
+ // TS-36 (remote side): the interp's interrupt seam is retail's
+ // interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
+ // chain (V2's reentrancy tests prove the wiring is inert-safe;
+ // R5-V5: the chain now lands on the literal facade relay 0x005241b0).
+ rm.Motion.InterruptCurrentMovement =
+ () => rmT.Movement.CancelMoveTo(
+ AcDream.Core.Physics.WeenieError.ActionCancelled);
+
+ // R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position
+ // is the entity's WORLD position (WorldEntity.Position — exactly the
+ // source the AP-79 poll used for a tracked target, so the voyeur system
+ // delivers the identical position for a moveto's quantum-0 case).
+ // HandleTargetting ticks per frame (added in the per-remote loop);
+ // HandleUpdateTarget fans deliveries to this entity's MoveToManager
+ // and (R5-V3, inside the host) its PositionManager — retail's
+ // CPhysicsObj::HandleUpdateTarget order.
+ var configuredHost = new EntityPhysicsHost(
+ serverGuid,
+ getPosition: () => new AcDream.Core.Physics.Position(
+ hostRecord.FullCellId,
+ hostRecord.WorldEntity?.Position ?? mtBody.Position,
+ mtBody.Orientation),
+ getVelocity: () => mtBody.Velocity,
+ getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
+ inContact: () => mtBody.OnWalkable,
+ minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(),
+ curTime: NowSeconds,
+ physicsTimerTime: NowSeconds,
+ getObjectA: ResolvePhysicsHost,
+ // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
+ // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
+ // relay to the moveto side); the host chains the PositionManager
+ // leg after it.
+ handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
+ interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
+ AcDream.Core.Physics.WeenieError.ActionCancelled));
+ host = EntityPhysicsHost.InstallOrRebind(
+ liveEntities,
+ hostRecord,
+ configuredHost);
+ rm.MarkFullPhysicsHostBound();
+
+ // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
+ // MoveToManager via the factory above (host now exists for the
+ // sticky binds inside it). The UM funnel head's unstick
+ // (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0
+ // routing sites) binds on the interp beside it.
+ rm.Movement.MakeMoveToManager();
+ rm.Motion.UnstickFromObject = host.PositionManager.UnStick;
+ return rm.Sink;
+ }
+
+ ///
+ /// R5-V2 — retail CObjectMaint::GetObjectA(id): resolve any entity's
+ /// by guid, the
+ /// cross-entity seam every host's GetObjectA uses. If the entity has
+ /// a bound host (from or
+ /// EnterPlayerModeNow) return it; otherwise, for any entity that
+ /// EXISTS in the world, lazily create a minimal position-only host. Retail
+ /// gives every CPhysicsObj the capacity to host a
+ /// target_manager — a STATIC object (chest, corpse) must still answer
+ /// add_voyeur so a mover can moveto/stick to it (without this,
+ /// auto-walk to a never-animated object would arm the moveto but never
+ /// receive the immediate target snapshot, and never start). The minimal
+ /// host is registered so subsequent lookups reuse it; it is NOT ticked
+ /// (a static object never drifts, so the AddVoyeur immediate snapshot is
+ /// all a watcher needs; despawn still fires ExitWorld via the registry).
+ ///
+ public AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
+ {
+ if (_liveEntities is not { } liveEntities)
+ return null;
+
+ bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord);
+ if (!isActive)
+ return null;
+
+ // TS-49: CObjCell::hide_object removes Hidden objects from the
+ // relationship surface until DetectionManager is ported. Pending,
+ // attached, and otherwise non-render-visible active objects remain in
+ // CObjectMaint's object table and must still resolve here.
+ if (liveEntities.IsHidden(id))
+ {
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ {
+ Console.WriteLine(
+ $"[autowalk-host-miss] object=0x{id:X8} "
+ + $"materialized={_entitiesByServerGuid.ContainsKey(id)} "
+ + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
+ + $"hidden={liveEntities.IsHidden(id)}");
+ }
+ return null;
+ }
+ if (liveEntities.TryGetPhysicsHost(id, out var existing))
+ return existing;
+
+ double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ var minimal = EntityPhysicsHost.CreateMinimal(
+ activeRecord,
+ ResolvePhysicsHost,
+ NowSeconds);
+ liveEntities.InstallPhysicsHost(activeRecord, minimal);
+ return minimal;
+ }
+
+ ///
+ /// R5-V3 (#171): retail CPartArray::GetRadius/GetHeight
+ /// (0x005180a0/0x005180b0 — setup->radius/height × scale; ACE
+ /// PartArray.cs:189-207 reads the same Setup-level fields). Returns
+ /// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) —
+ /// ACE's PartArray != null ? … : 0 fallback shape
+ /// (PhysicsObj.cs:951-952). Live spawns cache their Setup in
+ /// _physicsDataCache at spawn time (CacheSetup, ~3250), so this is
+ /// a dictionary hit for every creature.
+ ///
+ public (float Radius, float Height) GetSetupCylinder(
+ uint serverGuid, AcDream.Core.World.WorldEntity entity)
+ {
+ var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
+ if (setup is null)
+ return (0f, 0f);
+ // Live spawns bake ObjScale into MeshRefs and never populate
+ // WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the
+ // authoritative per-entity scale is the SPAWN RECORD's ObjScale, the
+ // same source the collision registration's entScale uses (~4137).
+ // entity.Scale remains the fallback for non-spawn entities (scenery —
+ // never a chase/sticky participant). Diff-review find: without this,
+ // a server-scaled creature variant read unscaled radii and kept the
+ // #171 interpenetration exactly for scaled bodies.
+ float scale =
+ _liveEntities.Snapshots.TryGetValue(serverGuid, out var sp)
+ && sp.ObjScale is { } objScale && objScale > 0f
+ ? objScale
+ : (entity.Scale > 0f ? entity.Scale : 1f);
+ return (setup.Radius * scale, setup.Height * scale);
+ }
+
+ // #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to
+ // AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no
+ // caller outside the DR tick; SyncRemoteShadowToBody is now called back via
+ // _remotePhysicsUpdater from the NPC UP-branch tail (below).
+
+ ///
+ /// R5-V4: retail CPhysicsObj::stick_to_object (0x005127e0) — the
+ /// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
+ /// motionFlags 0x1 trailer guid): resolve the target, read its PartArray
+ /// radius/height (0 when shapeless — retail's null-PartArray arm), then
+ /// PositionManager::StickTo. Unresolvable target → no stick at
+ /// all (retail's GetObjectA-null path). Retail resolves the target's
+ /// top-level PARENT first (parent ?? self); acdream's entity
+ /// table is flat (no client-side parenting), so the guid is used as-is —
+ /// the same convention as RouteServerMoveTo's TopLevelId.
+ ///
+ public void StickToObjectFromWire(
+ AcDream.Core.Physics.Motion.IPhysicsObjHost? host,
+ uint targetGuid)
+ {
+ if (host is not EntityPhysicsHost entityHost)
+ return;
+ if (_liveEntities is not { } liveEntities
+ || !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
+ return;
+ var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
+ entityHost.PositionManager.StickTo(targetGuid, radius, height);
+ }
+
+ public void ClearTargetForHiddenEntity(uint serverGuid)
+ {
+ if (_selectionInteractions() is { } interactions)
+ interactions.OnEntityHidden(serverGuid);
+ else if (_selection.SelectedObjectId == serverGuid)
+ _selection.Clear(
+ AcDream.Core.Selection.SelectionChangeSource.System,
+ AcDream.Core.Selection.SelectionChangeReason.Cleared);
+
+ if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true
+ && hiddenHost is EntityPhysicsHost hiddenEntityHost)
+ hiddenEntityHost.NotifyHidden();
+ }
+
+ ///
+ /// R4-V5: shared retail unpack_movement type-6..9 routing
+ /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes
+ /// (R4-V4) and the local player (R4-V5). Writes the wire run rate to
+ /// the interp (my_run_rate, unpack @300603/@300660 — plan M13),
+ /// builds the MovementStruct (mt 6/8 resolve the target guid
+ /// against the entity table; unresolvable degrades to
+ /// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
+ /// NOT an error), and calls the R5-V5 facade's PerformMovement
+ /// (MovementManager::PerformMovement 0x005240d0 — types 6-9
+ /// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager
+ /// at each head, a no-op here since the bind sites create eagerly).
+ /// Returns true when the event was a type-6..9 moveto (consumed); false
+ /// for every other movement type (caller falls through to its funnel /
+ /// skip posture).
+ ///
+ public bool RouteServerMoveTo(
+ AcDream.Core.Physics.Motion.MovementManager movement,
+ uint cellId,
+ AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
+ {
+ if (update.MotionState.IsServerControlledMoveTo
+ && update.MotionState.MoveToPath is { } path)
+ {
+ // my_run_rate write (unpack_movement @300603).
+ if (update.MotionState.MoveToRunRate is { } mtRunRate)
+ movement.Minterp.MyRunRate = mtRunRate;
+
+ var destWorld = AcDream.Core.Physics.Motion.MoveToMath
+ .OriginToWorld(
+ path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
+ _origin.CenterX, _origin.CenterY);
+ var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
+ path.Bitfield,
+ path.DistanceToObject,
+ path.MinDistance,
+ path.FailDistance,
+ update.MotionState.MoveToSpeed ?? 1f,
+ path.WalkRunThreshold,
+ path.DesiredHeading);
+
+ var ms = new AcDream.Core.Physics.MovementStruct
+ {
+ Params = mp,
+ };
+ // mt 6 with a resolvable target → MoveToObject (the P4 tracker
+ // feeds position updates per tick); else degrade to
+ // MoveToPosition at the wire origin (§2f).
+ if (update.MotionState.MovementType == 6
+ && path.TargetGuid is { } tgtGuid
+ && _liveEntities is { } liveMoveEntities
+ && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
+ {
+ ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
+ ms.ObjectId = tgtGuid;
+ ms.TopLevelId = tgtGuid;
+ // R5-V3 (#171): retail resolves the TARGET object's PartArray
+ // radius/height at the MoveToObject call site
+ // (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE
+ // PhysicsObj.cs:951-952) — they become SoughtObjectRadius/
+ // Height, feeding GetCurrentDistance's edge-to-edge arrival
+ // (UseSpheres) and the sticky-arrival handoff's target radius.
+ // Was unset (0): every attacker closed ~one body-radius deeper
+ // than retail before stopping — the #171 dogpile term.
+ (ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt);
+ ms.Pos = new AcDream.Core.Physics.Position(
+ cellId, tgtEnt.Position,
+ System.Numerics.Quaternion.Identity);
+ }
+ else
+ {
+ ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
+ ms.Pos = new AcDream.Core.Physics.Position(
+ cellId, destWorld,
+ System.Numerics.Quaternion.Identity);
+ }
+ movement.PerformMovement(ms);
+ return true;
+ }
+
+ if (update.MotionState.IsServerControlledTurnTo
+ && update.MotionState.TurnToPath is { } turnPath)
+ {
+ var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
+ turnPath.Bitfield,
+ turnPath.Speed,
+ turnPath.DesiredHeading);
+
+ var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
+ if (update.MotionState.MovementType == 8
+ && turnPath.TargetGuid is { } turnTgt
+ && _liveEntities is { } liveTurnEntities
+ && liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
+ {
+ ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
+ ms.ObjectId = turnTgt;
+ ms.TopLevelId = turnTgt;
+ ms.Pos = new AcDream.Core.Physics.Position(
+ cellId, turnEnt.Position,
+ System.Numerics.Quaternion.Identity);
+ }
+ else
+ {
+ ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
+ // Retail's mt-8 unresolvable-object fallback substitutes the
+ // STANDALONE wire heading into the params before degrading
+ // (decomp §2f case 8: `params.desired_heading = wire_heading`).
+ // Invisible against ACE (P6: both heading fields written from
+ // the same source) but required for the verbatim degrade —
+ // closes the V4 carry-over the adversarial review caught
+ // (TurnToPathData.WireHeading was parsed but never consumed).
+ if (update.MotionState.MovementType == 8
+ && turnPath.WireHeading is { } wireHeading)
+ {
+ mp.DesiredHeading = wireHeading;
+ }
+ }
+ movement.PerformMovement(ms);
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ {
+ string target = turnPath.TargetGuid is { } targetGuid
+ ? $"0x{targetGuid:X8}" : "null";
+ bool targetVisible = turnPath.TargetGuid is { } visibleGuid
+ && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid);
+ bool targetHost = turnPath.TargetGuid is { } hostGuid
+ && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
+ var moveTo = movement.MoveTo;
+ Console.WriteLine(
+ $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} "
+ + $"routed={ms.Type} target={target} visible={targetVisible} "
+ + $"host={targetHost} stop={mp.StopCompletelyFlag} "
+ + $"initialized={moveTo?.Initialized ?? false} "
+ + $"nodes={moveTo?.PendingActions.Count() ?? 0} "
+ + $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} "
+ + $"pendingMotions={movement.Minterp.MotionsPending()}");
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ // #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime
+ // 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is
+ // gone, TargetManager voyeur delivers positions) moved to
+ // RemotePhysicsUpdater. The DR tick was its only caller.
+
+ ///
+ /// Phase 6.6: the server says an entity's motion has changed. Look up
+ /// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the
+ /// new (stance, forward-command) override, and if the cycle is still
+ /// animated, swap in the new animation/frame range. Entities not in
+ /// the animated map (static props, entities rejected at spawn time)
+ /// are simply ignored — there's nothing to tick for them.
+ ///
+}
diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
new file mode 100644
index 00000000..a73c69eb
--- /dev/null
+++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
@@ -0,0 +1,1789 @@
+using AcDream.App.Combat;
+using AcDream.App.Input;
+using AcDream.App.Interaction;
+using AcDream.App.Physics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.World;
+using AcDream.Content;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Items;
+using AcDream.Core.Physics;
+using AcDream.Core.Selection;
+using AcDream.Core.World;
+using DatReaderWriter;
+
+namespace AcDream.App.Physics;
+
+///
+/// Update-thread owner of retail SmartBox's accepted Movement, Vector, State,
+/// and Position presentation transactions. Identity and timestamp authority
+/// remain canonical in ; this controller only
+/// routes an accepted exact incarnation into its App-layer owners.
+///
+internal sealed class LiveEntityNetworkUpdateController
+ : ILiveEntityNetworkUpdateSink,
+ ILiveEntitySameGenerationUpdateSink
+{
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ClientObjectTable _objects;
+ private readonly LiveEntityHydrationController _liveEntityHydration;
+ private readonly EntityEffectController _entityEffects;
+ private readonly LiveEntityPresentationController _liveEntityPresentation;
+ private readonly LiveEntityLightController _liveEntityLights;
+ private readonly EquippedChildRenderController _equippedChildRenderer;
+ private readonly ProjectileController _projectileController;
+ private readonly RemoteTeleportController _remoteTeleportController;
+ private readonly LiveEntityAnimationRuntimeView _animatedEntities;
+ private readonly LiveEntityRemoteMotionRuntimeView _remoteDeadReckon;
+ private readonly RemoteMovementObservationTracker _remoteMovementObservations;
+ private readonly RemotePhysicsUpdater _remotePhysicsUpdater;
+ private readonly RemoteInboundMotionDispatcher _remoteInboundMotion;
+ private readonly LiveEntityMotionRuntimeController _motionRuntime;
+ private readonly PhysicsEngine _physicsEngine;
+ private readonly IDatReaderWriter _dats;
+ private readonly IAnimationLoader _animLoader;
+ private readonly CombatTargetController? _combatTargetController;
+ private readonly LiveWorldOriginState _origin;
+ private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
+ WorldSession.EntityPositionUpdate> _teleportTransit;
+ private readonly Func _playerControllerSource;
+ private readonly LocalPlayerOutboundController _localPlayerOutbound;
+ private readonly Func _playerHostSource;
+ private readonly Func _playerGuid;
+ private readonly Func _gameTime;
+ private readonly Func _session;
+ private readonly LiveEntityInboundAuthorityGate _authorityGate;
+ private readonly Action _aimTeleportDestination;
+ private readonly Action
+ _dumpMovementTruthServerEcho;
+
+ private PlayerMovementController? _playerController => _playerControllerSource();
+ private EntityPhysicsHost? _playerHost => _playerHostSource();
+ private uint _playerServerGuid => _playerGuid();
+ private double _physicsScriptGameTime => _gameTime();
+ private IReadOnlyDictionary _entitiesByServerGuid =>
+ _liveEntities.MaterializedWorldEntities;
+ private IReadOnlyDictionary _visibleEntitiesByServerGuid =>
+ _liveEntities.WorldEntities;
+
+ internal uint? LastLivePlayerLandblockId =>
+ _authorityGate.LastLivePlayerLandblockId;
+
+ public LiveEntityNetworkUpdateController(
+ LiveEntityRuntime liveEntities,
+ ClientObjectTable objects,
+ LiveEntityHydrationController liveEntityHydration,
+ EntityEffectController entityEffects,
+ LiveEntityPresentationController liveEntityPresentation,
+ LiveEntityLightController liveEntityLights,
+ EquippedChildRenderController equippedChildRenderer,
+ ProjectileController projectileController,
+ RemoteTeleportController remoteTeleportController,
+ LiveEntityAnimationRuntimeView animatedEntities,
+ LiveEntityRemoteMotionRuntimeView remoteDeadReckon,
+ RemoteMovementObservationTracker remoteMovementObservations,
+ RemotePhysicsUpdater remotePhysicsUpdater,
+ RemoteInboundMotionDispatcher remoteInboundMotion,
+ LiveEntityMotionRuntimeController motionRuntime,
+ PhysicsEngine physicsEngine,
+ IDatReaderWriter dats,
+ IAnimationLoader animLoader,
+ CombatTargetController? combatTargetController,
+ LiveWorldOriginState origin,
+ AcDream.App.Streaming.TeleportTransitCoordinator teleportTransit,
+ Func playerControllerSource,
+ LocalPlayerOutboundController localPlayerOutbound,
+ Func playerHostSource,
+ Func playerGuid,
+ Func gameTime,
+ Func session,
+ Action publishTimestamps,
+ Action aimTeleportDestination,
+ Action dumpMovementTruthServerEcho)
+ {
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _objects = objects ?? throw new ArgumentNullException(nameof(objects));
+ _liveEntityHydration = liveEntityHydration ?? throw new ArgumentNullException(nameof(liveEntityHydration));
+ _entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
+ _liveEntityPresentation = liveEntityPresentation ?? throw new ArgumentNullException(nameof(liveEntityPresentation));
+ _liveEntityLights = liveEntityLights ?? throw new ArgumentNullException(nameof(liveEntityLights));
+ _equippedChildRenderer = equippedChildRenderer ?? throw new ArgumentNullException(nameof(equippedChildRenderer));
+ _projectileController = projectileController ?? throw new ArgumentNullException(nameof(projectileController));
+ _remoteTeleportController = remoteTeleportController ?? throw new ArgumentNullException(nameof(remoteTeleportController));
+ _animatedEntities = animatedEntities ?? throw new ArgumentNullException(nameof(animatedEntities));
+ _remoteDeadReckon = remoteDeadReckon ?? throw new ArgumentNullException(nameof(remoteDeadReckon));
+ _remoteMovementObservations = remoteMovementObservations ?? throw new ArgumentNullException(nameof(remoteMovementObservations));
+ _remotePhysicsUpdater = remotePhysicsUpdater ?? throw new ArgumentNullException(nameof(remotePhysicsUpdater));
+ _remoteInboundMotion = remoteInboundMotion ?? throw new ArgumentNullException(nameof(remoteInboundMotion));
+ _motionRuntime = motionRuntime ?? throw new ArgumentNullException(nameof(motionRuntime));
+ _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine));
+ _dats = dats ?? throw new ArgumentNullException(nameof(dats));
+ _animLoader = animLoader ?? throw new ArgumentNullException(nameof(animLoader));
+ _combatTargetController = combatTargetController;
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _teleportTransit = teleportTransit ?? throw new ArgumentNullException(nameof(teleportTransit));
+ _playerControllerSource = playerControllerSource ?? throw new ArgumentNullException(nameof(playerControllerSource));
+ _localPlayerOutbound = localPlayerOutbound
+ ?? throw new ArgumentNullException(nameof(localPlayerOutbound));
+ _playerHostSource = playerHostSource ?? throw new ArgumentNullException(nameof(playerHostSource));
+ _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
+ _gameTime = gameTime ?? throw new ArgumentNullException(nameof(gameTime));
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ _authorityGate = new LiveEntityInboundAuthorityGate(
+ liveEntities,
+ publishTimestamps);
+ _aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination));
+ _dumpMovementTruthServerEcho = dumpMovementTruthServerEcho ?? throw new ArgumentNullException(nameof(dumpMovementTruthServerEcho));
+ }
+
+ internal void ResetSessionState() => _authorityGate.ResetSessionState();
+
+ private static bool IsPlayerGuid(uint guid) =>
+ (guid & 0xFF000000u) == 0x50000000u;
+
+ private static bool IsDoorName(string? name) => name == "Door";
+
+ private bool RunRemoteTeleportHook(
+ uint serverGuid,
+ uint localEntityId,
+ Func isCurrent)
+ {
+ _remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
+ EntityPhysicsHost? host =
+ _liveEntities.TryGetPhysicsHost(serverGuid, out var registered)
+ ? registered as EntityPhysicsHost
+ : null;
+ return RemoteTeleportHook.Execute(
+ new RemoteTeleportHookActions(
+ CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
+ UnStick: () => host?.PositionManager.UnStick(),
+ StopInterpolating: () => remote?.Interp.Clear(),
+ UnConstrain: () => host?.PositionManager.UnConstrain(),
+ NotifyTeleported: () => host?.NotifyTeleported(),
+ ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)),
+ isCurrent);
+ }
+ public void ApplySameGeneration(
+ SameGenerationCreateObjectEvents refresh) =>
+ LiveEntitySameGenerationUpdateRouter.Apply(refresh, this);
+
+ void ILiveEntitySameGenerationUpdateSink.OnDescription(
+ uint ownerGuid,
+ PhysicsSpawnData description)
+ {
+ if (_liveEntities.TryGetEffectProfile(
+ ownerGuid,
+ out var effectProfile)
+ && effectProfile is EntityEffectProfile liveProfile)
+ {
+ liveProfile.ApplyNetworkDescription(description);
+ _entityEffects.OnLiveEntityDescriptionChanged(ownerGuid);
+ }
+ }
+
+ void ILiveEntitySameGenerationUpdateSink.OnAppearance(
+ AcDream.Core.Net.Messages.ObjDescEvent.Parsed appearance) =>
+ _liveEntityHydration.OnAppearance(appearance);
+
+ void ILiveEntitySameGenerationUpdateSink.OnParent(CreateParentUpdate parent) =>
+ _liveEntityHydration.OnCreateParentAccepted(parent);
+
+ void ILiveEntitySameGenerationUpdateSink.OnPosition(
+ WorldSession.EntityPositionUpdate position) => OnPosition(position);
+
+ void ILiveEntitySameGenerationUpdateSink.OnPickup(
+ AcDream.Core.Net.Messages.PickupEvent.Parsed pickup) =>
+ _liveEntityHydration.OnPickup(pickup);
+
+ void ILiveEntitySameGenerationUpdateSink.OnMovement(
+ WorldSession.EntityMotionUpdate movement) => OnMotion(movement);
+
+ void ILiveEntitySameGenerationUpdateSink.OnState(
+ AcDream.Core.Net.Messages.SetState.Parsed state) => OnState(state);
+
+ void ILiveEntitySameGenerationUpdateSink.OnVector(
+ AcDream.Core.Net.Messages.VectorUpdate.Parsed vector) => OnVector(vector);
+
+
+ public void OnMotion(AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
+ {
+ // L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation.
+ // Retail drops stale/duplicate/superseded movement events at
+ // DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) +
+ // CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer +
+ // SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
+ // straggler re-applies an old gait or un-stops a stop.
+ bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;
+ if (!_authorityGate.TryAcceptMotion(
+ update,
+ retainPayload,
+ out AcceptedMotionNetworkUpdate accepted,
+ out bool timestampAccepted))
+ {
+ if (!timestampAccepted
+ && (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
+ || Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
+ )
+ {
+ Console.WriteLine(
+ $"[UM_STALE] guid={update.Guid:X8} inst={update.InstanceSequence} "
+ + $"mov={update.MovementSequence} sc={update.ServerControlSequence} dropped");
+ }
+ return;
+ }
+
+ // R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous
+ // gate (0x00509690 @0050972e, raw 271370-271431) — a movement event
+ // whose wire autonomous byte is set is DROPPED ENTIRELY (no state
+ // application, no interrupt) when the addressed object IsThePlayer.
+ // ACE reflects the client's own outbound MoveToState back to the
+ // sender with IsAutonomous=1 hardcoded (MovementData.cs:162 +
+ // Player_Networking.cs:365) and retail never lets that echo reach
+ // unpack_movement — which is what makes the unconditional
+ // unpack-head interrupt in the player branch below safe against
+ // ACE. Order matches retail: the sequence gates above run FIRST.
+ // last_move_was_autonomous is NOT stored for dropped events (stored
+ // only on the unpack path). This retires the row-less "don't cancel
+ // on non-MoveTo UM" adaptation that lived here pre-V5 (its causal
+ // story was stale — V0-pins.md P1). Run-rate sync is re-anchored to
+ // retail's own feeds: PlayerDescription skills (SetCharacterSkills,
+ // K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the
+ // former ApplyServerRunRate echo tap is deleted, not gated.
+ LiveEntityRecord acceptedMotionRecord = accepted.Record;
+ ulong acceptedMovementAuthorityVersion =
+ accepted.MovementAuthorityVersion;
+ ulong acceptedMovementVelocityAuthorityVersion =
+ accepted.VelocityAuthorityVersion;
+
+ if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
+ if (!_animatedEntities.TryGetValue(entity.Id, out var ae))
+ {
+ DispatchRemoteInboundMotion(
+ update,
+ entity,
+ ae: null,
+ acceptedMotionRecord,
+ acceptedMovementAuthorityVersion,
+ acceptedMovementVelocityAuthorityVersion);
+ return;
+ }
+ if (_dats is null) return;
+
+ // Re-resolve using the new stance/command. Keep the setup and
+ // motion-table we already know about — the server's motion
+ // updates override state within the same table, not swap tables.
+ //
+ // IMPORTANT: stance and command are BOTH optional. Remote-player
+ // autonomous broadcasts frequently set only one flag (e.g. just
+ // ForwardCommand) with currentStyle=0x0000 meaning "no stance
+ // change — keep current." Treating stance=0 as "default stance"
+ // drops the real state; instead we preserve the sequencer's
+ // current style.
+ ushort stance = update.MotionState.Stance;
+ ushort? command = update.MotionState.ForwardCommand;
+
+ // A.1 (Commit A.1 2026-05-03): UM_RAW — every inbound UM, one line,
+ // gated on ACDREAM_REMOTE_VEL_DIAG=1. Skips the local player. Tells
+ // us the actual UM arrival rate per remote and which fields are set
+ // on each. The bug-suspect is "ACE sends UMs without ForwardCommand
+ // bit during running, our picker resolves to Ready, SetCycle(Ready)
+ // resets the cycle". This diag lets us count how often that happens.
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
+ && update.Guid != _playerServerGuid)
+ {
+ string cmdStrRaw = command.HasValue ? $"0x{command.Value:X4}" : "null";
+ string sideStr = update.MotionState.SideStepCommand is { } s ? $"0x{s:X4}" : "null";
+ string turnStr = update.MotionState.TurnCommand is { } t ? $"0x{t:X4}" : "null";
+ string fwdSpdStr = update.MotionState.ForwardSpeed is { } fs ? $"{fs:F2}" : "null";
+ uint seqMot = ae.Sequencer?.CurrentMotion ?? 0;
+ System.Console.WriteLine(
+ $"[UM_RAW] guid={update.Guid:X8} stance=0x{stance:X4} fwd={cmdStrRaw} fwdSpd={fwdSpdStr} "
+ + $"side={sideStr} turn={turnStr} mt=0x{update.MotionState.MovementType:X2} "
+ + $"isMoveTo={update.MotionState.IsServerControlledMoveTo} "
+ + $"seq.CurrentMotion=0x{seqMot:X8}");
+ }
+
+ // Diagnostic: dump every inbound UpdateMotion so we can trace why
+ // remote chars don't transition off RunForward when they stop.
+ // Enable with ACDREAM_DUMP_MOTION=1.
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
+ && update.Guid != _playerServerGuid)
+ {
+ string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
+ float spd = update.MotionState.ForwardSpeed
+ ?? ((update.MotionState.MoveToSpeed ?? 0f)
+ * (update.MotionState.MoveToRunRate ?? 0f));
+ uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
+ uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
+ Console.WriteLine(
+ $"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
+ $"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
+ }
+
+ // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation.
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
+ && IsDoorName(_objects.Get(update.Guid)?.Name))
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
+ }
+
+ // ── Sequencer path (preferred) ──────────────────────────────────
+ // Call SetCycle directly. The sequencer already handles:
+ // - left→right / backward→forward remapping via adjust_motion
+ // - style and motion as u32 MotionCommand values
+ // - fast-path for identical state
+ //
+ // When the server omits a field (stance flag not set, or command
+ // flag not set), "no change" means we must preserve the sequencer's
+ // current state, NOT fall back to a table default.
+ if (ae.Sequencer is not null)
+ {
+ uint fullStyle = stance != 0
+ ? (0x80000000u | (uint)stance)
+ : ae.Sequencer.CurrentStyle;
+
+ // ACE's stop signal: ForwardCommand flag CLEARED on the wire.
+ // Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags,
+ // when the player releases keys the InterpretedMotionState has
+ // ForwardCommand = Invalid (default) and BuildMovementFlags doesn't
+ // set bit 0x02 — so the field is absent. Retail's decompiled
+ // handler (FUN_005295D0 → FUN_0051F260 @ chunk_00510000.c:13957)
+ // bulk-copies Invalid/0 into the physics obj, which StopCompletely
+ // treats as "return to style default (Ready)."
+ //
+ // command == null → retail stop signal → Ready
+ // command.Value == 0 → explicit 0 (rare) → Ready
+ // otherwise → resolve class byte and use full cmd
+ float speedMod = update.MotionState.ForwardSpeed ?? 1f;
+ uint fullMotion;
+ // R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no
+ // longer flow through the interpreted funnel at all (retail
+ // unpack_movement routes types 6-9 to MoveToManager; only type
+ // 0 does the interpreted-state copy). The manager's own
+ // BeginMoveForward -> get_command -> _DoMotion produces the
+ // cycle through the same sink every other motion uses.
+ if (!command.HasValue || command.Value == 0)
+ {
+ fullMotion = 0x41000003u;
+ }
+ else
+ {
+ // Use MotionCommandResolver to restore the proper class
+ // byte from the wire's 16-bit ForwardCommand.
+ uint resolved = AcDream.Core.Physics.MotionCommandResolver
+ .ReconstructFullCommand(command.Value);
+ fullMotion = resolved != 0
+ ? resolved
+ : (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value;
+ if (fullMotion == (uint)command.Value) // no class bits yet
+ fullMotion = 0x40000000u | (uint)command.Value;
+ }
+
+ // ForwardSpeed from the InterpretedMotionState (flag 0x04).
+ // ACE omits this field when speed == 1.0 (only sets the flag
+ // when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101).
+ // So:
+ // - field absent → default 1.0 (normal speed)
+ // - field present → USE THE VALUE, including zero.
+ //
+ // Zero is a VALID stop signal: when the retail client releases
+ // W, ACE broadcasts WalkForward with ForwardSpeed=0 (via
+ // apply_run_to_command). Treating zero as "unspecified / 1.0"
+ // produces "slow walk that never stops" — exactly what the
+ // stop bug looked like.
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
+ && update.Guid != _playerServerGuid)
+ Console.WriteLine(
+ $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})");
+
+ // No-op if same; the sequencer's fast path guards against that.
+ uint priorMotion = ae.Sequencer.CurrentMotion;
+
+ // The SetObjectMovement gate above already rejects local
+ // autonomous echoes. A local event reaching this point is
+ // server-authored, so retail applies it through the interpreted
+ // funnel. This is especially important for attacks: ACE chooses
+ // the exact swing and carries it in Commands[].
+ if (update.Guid == _playerServerGuid)
+ {
+ // B.6 slice 1 (2026-05-14): trace inbound motion for the
+ // local player. One line per inbound UM, gated on
+ // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ {
+ string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null";
+ string pathStr = update.MotionState.MoveToPath is { } p
+ ? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}"
+ : "path=null";
+ string spd = update.MotionState.ForwardSpeed is { } fs
+ ? $"fwdSpd={fs:F2}"
+ : "fwdSpd=null";
+ string mtsSpd = update.MotionState.MoveToSpeed is { } ms
+ ? $"mtSpd={ms:F2}"
+ : "mtSpd=null";
+ string mtsRun = update.MotionState.MoveToRunRate is { } mr
+ ? $"mtRun={mr:F2}"
+ : "mtRun=null";
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
+ }
+
+ // R4-V5: retail unpack_movement dispatch for the local
+ // player — the SAME shape the remote branch uses below.
+ // Head (@300566): interrupt + unstick fire for EVERY
+ // movement event that reached unpack (the P1 gate above
+ // already dropped the autonomous echoes that would have
+ // made this unsafe against ACE); then types 6-9 route to
+ // the player's MoveToManager. mt-0 falls through to the
+ // interpreted-state copy below; LastMoveWasAutonomous=false
+ // is the local equivalent of LoseControlToServer until the
+ // next user-input edge takes control back.
+ if (_playerController is not null)
+ {
+ // P1 tail (00509730): the unpack path stores the wire
+ // autonomous byte BEFORE unpack_movement — always false
+ // here (the gate above dropped autonomous events). This
+ // is what routes the controller's per-tick pump (A3
+ // dual dispatch) to the INTERPRETED branch during a
+ // server moveto. LOCAL PLAYER ONLY for now: remotes'
+ // interps have no WeenieObj, which A3 treats as
+ // IsThePlayer — storing a remote player's autonomous
+ // byte would flip their per-tick apply onto the raw
+ // branch and clobber their funnel state; the remote
+ // store lands with real remote weenies (R5+).
+ _playerController.SetLastMoveWasAutonomous(update.IsAutonomous);
+ bool IsCurrentLocalMotion() =>
+ _liveEntities.IsCurrentMovementAuthority(
+ acceptedMotionRecord,
+ acceptedMovementAuthorityVersion)
+ && _liveEntities.IsCurrentVelocityAuthority(
+ acceptedMotionRecord,
+ acceptedMovementVelocityAuthorityVersion)
+ && ReferenceEquals(
+ acceptedMotionRecord.WorldEntity,
+ entity);
+ if (!IsCurrentLocalMotion())
+ return;
+
+ // Local and remote packets now share the literal
+ // MovementManager::unpack_movement funnel. Besides
+ // removing duplicate retail ordering, the authority
+ // predicate is rechecked after every callback boundary;
+ // a nested newer packet can never be overwritten by the
+ // tail of this older one.
+ AcDream.App.Physics.RemoteInboundMotionDispatchResult localDispatch =
+ _remoteInboundMotion.Apply(
+ update,
+ _playerController.Movement,
+ _playerController.Motion.DefaultSink,
+ _playerHost,
+ _playerController.CellId,
+ ae.Sequencer.CurrentMotion & 0xFF000000u,
+ IsCurrentLocalMotion);
+ if (localDispatch.Superseded
+ || !IsCurrentLocalMotion())
+ {
+ return;
+ }
+ if (localDispatch.RoutedMoveTo)
+ {
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}"));
+ }
+ return;
+ }
+ if (!localDispatch.AppliedInterpretedState)
+ return;
+ fullMotion = localDispatch.CurrentForwardCommand;
+ }
+ }
+ else
+ {
+ // One packet owner handles both PartArray-backed remotes and
+ // AP-77's animation-less body fallback. GameWindow performs
+ // only live-owner lookup and supplies the optional sink.
+ AcDream.App.Physics.RemoteInboundMotionDispatchResult dispatch =
+ DispatchRemoteInboundMotion(
+ update,
+ entity,
+ ae,
+ acceptedMotionRecord,
+ acceptedMovementAuthorityVersion,
+ acceptedMovementVelocityAuthorityVersion);
+ if (dispatch.Superseded
+ || dispatch.RoutedMoveTo
+ || !dispatch.AppliedInterpretedState)
+ return;
+ fullMotion = dispatch.CurrentForwardCommand;
+ }
+
+ // Authoritative Dead motion invalidates a selected combat target.
+ // The controller clears shared selection, whose SelectionChanged
+ // consumer ports retail's post-clear AutoTarget behavior.
+ _combatTargetController?.OnMotionApplied(
+ update.Guid, ae.Sequencer.CurrentMotion);
+ if (!_liveEntities.IsCurrentMovementAuthority(
+ acceptedMotionRecord,
+ acceptedMovementAuthorityVersion)
+ || !_liveEntities.IsCurrentVelocityAuthority(
+ acceptedMotionRecord,
+ acceptedMovementVelocityAuthorityVersion))
+ {
+ return;
+ }
+
+ // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
+ // stamp the remote observation timestamp to "now". Without this,
+ // the stop-detection loop in TickAnimations sees the previous
+ // observation timestamp (set by the last UpdatePosition,
+ // often >300ms ago during idle) and fires the stop signal
+ // IMMEDIATELY — flipping the sequencer straight back to Ready.
+ // The visible symptom was "remote char never animates; just
+ // stands there, teleporting position every UpdatePosition."
+ // Fresh timestamp gives the stop-timer a full 300ms window to
+ // observe genuine position stagnation before reverting.
+ uint newLo = fullMotion & 0xFFu;
+ bool enteringLocomotion = newLo == 0x05 || newLo == 0x06
+ || newLo == 0x07
+ || newLo == 0x0F || newLo == 0x10;
+ uint oldLo = priorMotion & 0xFFu;
+ bool wasLocomotion = oldLo == 0x05 || oldLo == 0x06
+ || oldLo == 0x07
+ || oldLo == 0x0F || oldLo == 0x10;
+ if (enteringLocomotion && !wasLocomotion && update.Guid != _playerServerGuid)
+ {
+ // Reset both stop signals so stop-detection starts a fresh
+ // window from this transition. Without this, the entity
+ // starts its run animation and is instantly interrupted.
+ var refreshedTime = System.DateTime.UtcNow;
+ if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
+ _remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime);
+ if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr))
+ dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
+ }
+
+ return;
+ }
+
+ // ── Legacy path (entities without a sequencer) ──────────────────
+ // Here we DO use GetIdleCycle because the legacy tick loop needs
+ // a concrete Animation + frame range. Only swap when the resolver
+ // returns a clearly-better cycle.
+ var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
+ ae.Setup, _dats, _animLoader!,
+ motionTableIdOverride: null,
+ stanceOverride: stance,
+ commandOverride: command);
+ bool newCycleIsGood = newCycle is not null
+ && newCycle.Framerate != 0f
+ && newCycle.HighFrame >= newCycle.LowFrame
+ && newCycle.Animation.PartFrames.Count >= 1;
+ if (!newCycleIsGood) return;
+
+ ae.Animation = newCycle!.Animation;
+ ae.LowFrame = Math.Max(0, newCycle.LowFrame);
+ ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
+ ae.Framerate = newCycle.Framerate;
+ ae.CurrFrame = ae.LowFrame;
+ }
+
+ ///
+ /// Resolves one live remote owner and delegates retail's entire
+ /// unpack_movement body to the shared animation-optional packet
+ /// dispatcher. The render PartArray contributes only its optional sink.
+ ///
+ private AcDream.App.Physics.RemoteInboundMotionDispatchResult
+ DispatchRemoteInboundMotion(
+ AcDream.Core.Net.WorldSession.EntityMotionUpdate update,
+ AcDream.Core.World.WorldEntity entity,
+ LiveEntityAnimationState? ae,
+ LiveEntityRecord acceptedRecord,
+ ulong acceptedMovementAuthorityVersion,
+ ulong acceptedVelocityAuthorityVersion)
+ {
+ if (update.Guid == _playerServerGuid)
+ return default;
+
+ bool IsCurrentOwner(RemoteMotion? expectedRemote = null) =>
+ _liveEntities is { } live
+ && live.IsCurrentMovementAuthority(
+ acceptedRecord,
+ acceptedMovementAuthorityVersion)
+ && live.IsCurrentVelocityAuthority(
+ acceptedRecord,
+ acceptedVelocityAuthorityVersion)
+ && ReferenceEquals(acceptedRecord.WorldEntity, entity)
+ && (ae is null
+ ? acceptedRecord.AnimationRuntime is null
+ : ReferenceEquals(acceptedRecord.AnimationRuntime, ae))
+ && (expectedRemote is null
+ || ReferenceEquals(
+ acceptedRecord.RemoteMotionRuntime,
+ expectedRemote));
+ if (!IsCurrentOwner())
+ return default;
+
+ if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remote))
+ {
+ remote = CreateRemoteMotion(update.Guid);
+ remote.Body.Orientation = entity.Rotation;
+ remote.Body.Position = entity.Position;
+ _remoteDeadReckon[update.Guid] = remote;
+ }
+ if (!IsCurrentOwner(remote))
+ return default;
+
+ var sink = _motionRuntime.EnsureRemoteMotionBindings(remote, ae, update.Guid);
+ uint commandClass = ae?.Sequencer?.CurrentMotion & 0xFF000000u
+ ?? remote.Motion.InterpretedState.ForwardCommand & 0xFF000000u;
+ if (commandClass == 0u)
+ commandClass = 0x41000000u;
+
+ AcDream.App.Physics.RemoteInboundMotionDispatchResult result =
+ _remoteInboundMotion.Apply(
+ update,
+ remote.Movement,
+ sink,
+ remote.Host,
+ remote.CellId,
+ commandClass,
+ () => IsCurrentOwner(remote));
+
+ if (result.Superseded || !IsCurrentOwner(remote))
+ return result with { Superseded = true };
+
+ if (result.ForwardCommandChanged)
+ {
+ if (System.Environment.GetEnvironmentVariable(
+ "ACDREAM_REMOTE_VEL_DIAG") == "1")
+ {
+ System.Console.WriteLine(
+ $"[FWD_WIRE] guid={update.Guid:X8} "
+ + $"oldCmd=0x{result.PreviousForwardCommand:X8} "
+ + $"newCmd=0x{result.CurrentForwardCommand:X8} "
+ + $"newLow=0x{result.CurrentForwardCommand & 0xFFu:X2} "
+ + $"speed={update.MotionState.ForwardSpeed ?? 1f:F3}");
+ }
+ remote.PrevServerPosTime = 0.0;
+ }
+
+ if (result.AppliedInterpretedState && ae is null)
+ {
+ _combatTargetController?.OnMotionApplied(
+ update.Guid,
+ result.CurrentForwardCommand);
+ if (!IsCurrentOwner(remote))
+ return result with { Superseded = true };
+ }
+ return result;
+ }
+
+ ///
+ /// Phase 6.7: the server says an entity moved. Translate its new
+ /// landblock-local position into acdream world space (same math as
+ /// CreateObject hydration) and update the entity's Position/Rotation
+ /// in place so the next Draw picks up the new transform.
+ ///
+ /// Phase B.3 extension: if the player controller is in PortalSpace and
+ /// this update is for our own character, detect a large position change
+ /// (different landblock or > 100 units distance). If detected, recenter
+ /// the streaming controller, resolve the new position through physics,
+ /// snap the player entity + controller, and return to InWorld. Also sends
+ /// LoginComplete so the server knows the client has loaded the destination.
+ ///
+ ///
+ /// Reports whether the exact remote component currently belongs to the
+ /// visible ordinary-object workset that consumes interpolation targets.
+ ///
+ private bool WillAdvanceRemoteMotion(uint serverGuid, RemoteMotion remote)
+ {
+ return _liveEntities is { } runtime
+ && runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)
+ && ReferenceEquals(record.RemoteMotionRuntime, remote)
+ && (record.FinalPhysicsState
+ & AcDream.Core.Physics.PhysicsStateFlags.Static) == 0
+ && runtime.GetRootObjectClockDisposition(serverGuid)
+ is AcDream.Core.Physics.RetailObjectClockDisposition.Advance
+ && runtime.IsCurrentSpatialRemoteMotion(record, remote);
+ }
+
+ ///
+ /// Creates the optional MovementManager companion for one live object.
+ /// A retained projectile or static-animation owner contributes the
+ /// already-owned CPhysicsObj body; an ordinary remote gets local storage.
+ ///
+ private RemoteMotion CreateRemoteMotion(uint serverGuid)
+ {
+ LiveEntityRecord? record = null;
+ if (_liveEntities is { } liveEntities
+ && liveEntities.TryGetRecord(serverGuid, out var currentRecord))
+ {
+ record = currentRecord;
+ }
+
+ RemoteMotion remote;
+ if (record?.PhysicsBody is { } canonicalBody)
+ {
+ // Retail has one CPhysicsObj. A projectile or Physics-Static
+ // animation workset can create it before the MovementManager;
+ // adopt its current frame/vectors without replaying CreateObject.
+ remote = new RemoteMotion(canonicalBody);
+ }
+ else
+ {
+ remote = new RemoteMotion();
+ if (record is not null)
+ AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize(
+ remote.Body,
+ record);
+ }
+
+ RemoteMotion incarnation = remote;
+ remote.Movement.ActivatePhysicsObject = () =>
+ _liveEntities?.TryActivateOrdinaryObject(serverGuid, incarnation);
+ return remote;
+ }
+
+ ///
+ /// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps.
+ /// The payload seeds the world-space launch velocity and angular velocity.
+ ///
+ public void OnVector(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
+ {
+ bool payloadIsValid = _projectileController?.CanAcceptVectorPayload(
+ update.Guid,
+ update.Velocity,
+ update.Omega) != false;
+ if (!_authorityGate.TryAcceptVector(
+ update,
+ payloadIsValid,
+ out AcceptedVectorNetworkUpdate accepted))
+ {
+ return;
+ }
+ LiveEntityRecord acceptedVectorRecord = accepted.Record;
+ ulong acceptedVectorAuthorityVersion =
+ accepted.VectorAuthorityVersion;
+ ulong acceptedVectorVelocityAuthorityVersion =
+ accepted.VelocityAuthorityVersion;
+
+ LiveEntityVectorRouter.Route(
+ () => _projectileController?.ApplyAuthoritativeVector(
+ acceptedVectorRecord,
+ acceptedVectorAuthorityVersion,
+ acceptedVectorVelocityAuthorityVersion,
+ update.Velocity,
+ update.Omega,
+ _physicsScriptGameTime) == true,
+ () =>
+ {
+ // A Physics-Static animation owner can own the canonical
+ // CPhysicsObj before any MovementManager exists. F74E writes
+ // directly to that body and must not manufacture a remote.
+ if (update.Guid == _playerServerGuid
+ || acceptedVectorRecord.RemoteMotionRuntime is not null
+ || acceptedVectorRecord.PhysicsBody is not { } canonicalBody)
+ {
+ return false;
+ }
+ _liveEntities.TryCommitAuthoritativeVector(
+ acceptedVectorRecord,
+ canonicalBody,
+ update.Velocity,
+ update.Omega,
+ _physicsScriptGameTime);
+ return true;
+ },
+ () => ApplyOrdinaryVector(
+ update,
+ acceptedVectorRecord,
+ acceptedVectorAuthorityVersion,
+ acceptedVectorVelocityAuthorityVersion));
+ }
+
+ private void ApplyOrdinaryVector(
+ AcDream.Core.Net.Messages.VectorUpdate.Parsed update,
+ LiveEntityRecord acceptedVectorRecord,
+ ulong acceptedVectorAuthorityVersion,
+ ulong acceptedVectorVelocityAuthorityVersion)
+ {
+ if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
+
+ if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
+ if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return;
+ LiveEntityRecord remoteRecord = acceptedVectorRecord;
+
+ // World-space velocity. Apply directly to the body — the per-tick
+ // remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt².
+ // L.3.1 Task 6: apply Omega too. LiveEntityRuntime commits both
+ // writes to the one canonical CPhysicsObj and wakes its retained
+ // update_time clock on the same non-Static edge.
+ if (!_liveEntities.TryCommitAuthoritativeVector(
+ remoteRecord,
+ rm.Body,
+ update.Velocity,
+ update.Omega,
+ _physicsScriptGameTime))
+ {
+ return;
+ }
+
+ // Mark airborne when the launch has meaningful +Z. Threshold
+ // 0.5 m/s rejects noise / horizontal-only updates (server might
+ // also use VectorUpdate for non-jump events). The per-tick
+ // remote update reads .Airborne to skip the ground-clamp branch
+ // and apply gravity instead.
+ if (update.Velocity.Z > 0.5f)
+ {
+ rm.Airborne = true;
+ // Clear ground-contact bits + enable gravity so calc_acceleration
+ // returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then
+ // produces the parabolic arc.
+ rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact
+ | AcDream.Core.Physics.TransientStateFlags.OnWalkable);
+ rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
+
+ // R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism.
+ // The remote's ground departure fires LeaveGround (0x00528b00):
+ // strips pending transition links (the RemoveLinkAnimations
+ // seam) + re-applies movement through DefaultSink, whose
+ // contact-gated funnel dispatch engages Falling — no forced
+ // SetCycle, no skip flag. The wire velocity/omega are re-applied
+ // AFTER so they stay authoritative over LeaveGround's
+ // state-derived velocity write (adaptation note: retail's
+ // equivalence comes from the per-tick transition-sweep order —
+ // R6 scope).
+ if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent)
+ && _animatedEntities.TryGetValue(ent.Id, out var ae)
+ && ae.Sequencer is not null)
+ {
+ _motionRuntime.EnsureRemoteMotionBindings(rm, ae, update.Guid);
+ rm.Motion.LeaveGround();
+ if (!_liveEntities.IsCurrentVectorAuthority(
+ remoteRecord,
+ acceptedVectorAuthorityVersion)
+ || !_liveEntities.IsCurrentVelocityAuthority(
+ remoteRecord,
+ acceptedVectorVelocityAuthorityVersion)
+ || !_liveEntities.TryCommitAuthoritativeVector(
+ remoteRecord,
+ rm.Body,
+ update.Velocity,
+ update.Omega,
+ _physicsScriptGameTime))
+ {
+ return;
+ }
+ }
+ }
+
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
+ {
+ Console.WriteLine(
+ $"VU guid=0x{update.Guid:X8} vel=({update.Velocity.X:F2},{update.Velocity.Y:F2},{update.Velocity.Z:F2}) airborne={rm.Airborne}");
+ }
+ }
+
+ ///
+ /// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the
+ /// new PhysicsState bits into ShadowObjectRegistry so the
+ /// existing check honors
+ /// the flip on the next resolver tick. Chiefly doors:
+ /// server flips ETHEREAL_PS = 0x4 on Use, the door's
+ /// cylinder collision stops blocking the threshold.
+ ///
+ public void OnState(AcDream.Core.Net.Messages.SetState.Parsed parsed)
+ {
+ if (!_authorityGate.TryAcceptState(
+ parsed,
+ out AcceptedStateNetworkUpdate accepted))
+ return;
+ LiveEntityRecord record = accepted.Record;
+ ulong acceptedStateAuthorityVersion = accepted.StateAuthorityVersion;
+
+ // Retail set_state order: Lighting, NoDraw, then Hidden. The live
+ // runtime already committed the raw/final bits and draw visibility;
+ // apply the ordered owners before updating motion/collision consumers.
+ _liveEntityLights?.OnStateChanged(parsed.Guid);
+ _liveEntityPresentation?.OnStateAccepted(parsed.Guid);
+
+ if (!_liveEntities.IsCurrentStateAuthority(
+ record,
+ acceptedStateAuthorityVersion))
+ {
+ return;
+ }
+
+ _projectileController?.ApplyAuthoritativeState(
+ record,
+ acceptedStateAuthorityVersion,
+ record.FinalPhysicsState,
+ _physicsScriptGameTime,
+ _origin.CenterX,
+ _origin.CenterY);
+ if (!_liveEntities.IsCurrentStateAuthority(
+ record,
+ acceptedStateAuthorityVersion))
+ {
+ return;
+ }
+ if (parsed.Guid == _playerServerGuid)
+ _playerController?.ApplyPhysicsState(record.FinalPhysicsState);
+
+ if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
+
+ // L.2g slice 1c (2026-05-13): the server addresses entities by
+ // ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but
+ // ShadowObjectRegistry's cell index is keyed by local entity.Id
+ // (e.g. 0x000F4245). Translate via _entitiesByServerGuid before
+ // mutating the registry — otherwise the lookup misses and the
+ // state flip silently no-ops, leaving doors blocked even though
+ // ACE flipped the ETHEREAL bit.
+ uint registryKey = entity.Id;
+
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
+ }
+
+ public void OnPosition(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
+ {
+ bool payloadIsValid = _projectileController?.CanAcceptPositionPayload(
+ update.Guid,
+ update.Position,
+ update.Velocity) != false;
+ if (!_authorityGate.TryAcceptPosition(
+ update,
+ _playerServerGuid,
+ update.Guid == _playerServerGuid && _playerController is not null
+ ? _playerController.BodyOrientation
+ : null,
+ update.Guid == _playerServerGuid && _playerController is not null
+ ? _playerController.BodyVelocity
+ : null,
+ payloadIsValid,
+ out AcceptedPositionNetworkUpdate accepted))
+ {
+ return;
+ }
+ var timestampDisposition = accepted.TimestampDisposition;
+ var acceptedSpawn = accepted.Spawn;
+ var timestamps = accepted.Timestamps;
+ LiveEntityRecord acceptedPositionRecord = accepted.Record;
+ ulong acceptedPositionAuthorityVersion =
+ accepted.PositionAuthorityVersion;
+ ulong acceptedPositionVelocityAuthorityVersion =
+ accepted.VelocityAuthorityVersion;
+ bool IsCurrentPositionOwner(
+ AcDream.Core.World.WorldEntity? expectedEntity = null) =>
+ _liveEntities.IsCurrentPositionAuthority(
+ acceptedPositionRecord,
+ acceptedPositionAuthorityVersion)
+ && (expectedEntity is null
+ || ReferenceEquals(
+ acceptedPositionRecord.WorldEntity,
+ expectedEntity));
+ if (!IsCurrentPositionOwner())
+ return;
+
+ // A PlayerDescription/CreateObject may establish the live record
+ // without either Position or enough render data. Bind streaming
+ // readiness directly to this first accepted canonical Position before
+ // translating it through the current world origin; projection recovery
+ // below is a separate concern and may never be needed for UI-only state.
+ if (_liveEntityHydration?.EnsureWorldOrigin(
+ acceptedPositionRecord,
+ acceptedPositionAuthorityVersion,
+ acceptedSpawn) != true
+ || !IsCurrentPositionOwner())
+ return;
+
+ var p = update.Position;
+ int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
+ int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
+ var origin = new System.Numerics.Vector3(
+ (lbX - _origin.CenterX) * 192f,
+ (lbY - _origin.CenterY) * 192f,
+ 0f);
+ var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
+
+ bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
+ && update.Guid == _playerServerGuid
+ && _playerController is not null;
+ if (!LocalForcePositionTransaction.Apply(
+ forceLocal,
+ () => IsCurrentPositionOwner(),
+ () => _playerController!.BlipPosition(
+ worldPos,
+ p.LandblockId,
+ new System.Numerics.Vector3(
+ p.PositionX,
+ p.PositionY,
+ p.PositionZ)),
+ () => _localPlayerOutbound.SendImmediatePosition(
+ _session(),
+ _playerController)))
+ return;
+
+ if (!_entitiesByServerGuid.ContainsKey(update.Guid))
+ {
+ if (!IsCurrentPositionOwner())
+ return;
+ AcDream.App.Rendering.ChildUnparentDisposition unparented =
+ _equippedChildRenderer?.OnChildBecameUnparented(
+ update.Guid,
+ () =>
+ {
+ if (!IsCurrentPositionOwner())
+ return;
+ _liveEntityHydration!.RecoverProjection(
+ acceptedPositionRecord,
+ acceptedPositionAuthorityVersion,
+ acceptedSpawn);
+ })
+ ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
+ if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded
+ or AcDream.App.Rendering.ChildUnparentDisposition.Pending)
+ return;
+ if (!IsCurrentPositionOwner())
+ return;
+ if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached)
+ {
+ _liveEntityHydration!.RecoverProjection(
+ acceptedPositionRecord,
+ acceptedPositionAuthorityVersion,
+ acceptedSpawn);
+ if (!IsCurrentPositionOwner())
+ return;
+ }
+ }
+
+ if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
+ if (!IsCurrentPositionOwner(entity))
+ return;
+ _entityEffects?.MarkLiveOwnerPoseDirty(update.Guid);
+ if (!IsCurrentPositionOwner(entity))
+ return;
+
+ // Phase A.1 / #135: track the PLAYER's last server-known landblock so the
+ // streaming controller can follow the player in the fly-camera / pre-player-mode
+ // (login hold) views. Filtered to our OWN character guid — resolving the original
+ // Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
+ // must NOT move the streaming observer: during a dungeon-login hold (player not
+ // yet placed, so _playerController is null and the PortalSpace observer branch
+ // can't apply) that would drift the observer off the pre-collapsed dungeon
+ // landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
+ // the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
+ // (~line 1984) before world entry, so it is valid by the time updates arrive.
+ if (update.Guid == _playerServerGuid)
+ _authorityGate.ObserveAcceptedLocalPosition(update.Position.LandblockId);
+
+ // B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for
+ // the local player. Combined with [autowalk-mt] this answers
+ // whether ACE's broadcast frequency during a server-initiated
+ // auto-walk is dense enough to drive smooth visible motion (the
+ // Option C viability check from the design spec). Gated on
+ // ACDREAM_PROBE_AUTOWALK=1; skips remote entities.
+ if (update.Guid == _playerServerGuid
+ && AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ {
+ string velStr = update.Velocity is { } v
+ ? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})"
+ : "vel=null";
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}"));
+ }
+ var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
+ ? entity.Rotation
+ : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
+ _dumpMovementTruthServerEcho(update, worldPos);
+
+ bool remoteHardTeleport = update.Guid != _playerServerGuid
+ && timestamps.TeleportHookRequired;
+ bool remotePlacementRequired = update.Guid != _playerServerGuid
+ && (remoteHardTeleport
+ || _remoteTeleportController?.HasPending(update.Guid) == true);
+ if (remoteHardTeleport)
+ {
+ if (!RunRemoteTeleportHook(
+ update.Guid,
+ entity.Id,
+ () => IsCurrentPositionOwner(entity)))
+ {
+ return;
+ }
+ }
+
+ // Missiles reconcile the same predicted PhysicsBody in place. The
+ // timestamp gate above already rejected stale corrections; returning
+ // here prevents the generic remote locomotion path from allocating a
+ // second body or interpolation owner for the projectile.
+ if (_projectileController?.ApplyAuthoritativePosition(
+ acceptedPositionRecord,
+ acceptedPositionAuthorityVersion,
+ acceptedPositionVelocityAuthorityVersion,
+ worldPos,
+ new System.Numerics.Vector3(
+ p.PositionX,
+ p.PositionY,
+ p.PositionZ),
+ rot,
+ acceptedSpawn.Physics?.Velocity
+ ?? System.Numerics.Vector3.Zero,
+ p.LandblockId,
+ _physicsScriptGameTime,
+ _origin.CenterX,
+ _origin.CenterY) == true)
+ return;
+
+ if (!_liveEntities.TryGetRecord(
+ update.Guid,
+ out LiveEntityRecord positionRecord)
+ || !ReferenceEquals(positionRecord, acceptedPositionRecord)
+ || !ReferenceEquals(positionRecord.WorldEntity, entity)
+ || !_liveEntities.IsCurrentPositionAuthority(
+ positionRecord,
+ acceptedPositionAuthorityVersion))
+ {
+ 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;
+ if (!_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId)
+ || !_liveEntities.TryGetRecord(
+ update.Guid,
+ out LiveEntityRecord afterRebucket)
+ || !ReferenceEquals(afterRebucket, positionRecord)
+ || !ReferenceEquals(afterRebucket.WorldEntity, entity)
+ || !_liveEntities.IsCurrentPositionAuthority(
+ afterRebucket,
+ acceptedPositionAuthorityVersion))
+ {
+ // A projection callback superseded or deleted this incarnation.
+ // Never let an older UpdatePosition seed the replacement's
+ // placement, interpolation, or collision state.
+ return;
+ }
+
+ if (remotePlacementRequired)
+ {
+ _remoteTeleportController!.BeginPlacement(
+ update.Guid,
+ acceptedSpawn.InstanceSequence);
+ if (!IsCurrentPositionOwner(entity))
+ return;
+ }
+
+ // Commit B 2026-04-29 — keep the shadow registry in sync with
+ // server-authoritative position so the player's collision broadphase
+ // tests against the up-to-date target body. Skip the local player
+ // (its body is the simulator, not a target). Retail does the
+ // equivalent via SetPosition → change_cell → AddShadowObject
+ // (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
+ // #184 Slice 2b: the former players-only RAW-pos shadow sync is RETIRED.
+ // It was a Slice-1 stopgap while grounded player remotes (old Path A) skipped
+ // the sweep and tracked the server position closely. Now that Slice 2b runs
+ // the SAME per-tick sweep + shadow-follows-resolved for players, writing the
+ // raw (overlapping) server pos here would re-snap a packed player's shadow
+ // into overlap once per UP and fight the in-tick de-overlap (research
+ // finding 9). Player shadows now follow the RESOLVED body — via the DR-tick
+ // loop (SyncRemoteShadowToBody, pose/cell-gated) and the player UP-branch tail
+ // below (first-UP / no-Sequencer case), exactly like NPCs. Local-player
+ // broadphase still tests an up-to-date remote shadow; it is just the resolved
+ // body now, not the raw wire pos.
+
+// Track remote-entity motion for stop detection. Only record the
+ // timestamp when position moved MEANINGFULLY (> 0.05m). Updates
+ // that report the same position keep the old Time, so the
+ // TickAnimations check can see when motion last changed.
+ //
+ // Also populate the dead-reckon state so TickAnimations can
+ // integrate velocity between server updates and avoid teleport jitter.
+ // Observed-velocity is computed from the position delta across
+ // consecutive updates — this is the fallback when the motion table's
+ // MotionData.Velocity is zero (NPCs without HasVelocity).
+ if (update.Guid != _playerServerGuid)
+ {
+ var now = System.DateTime.UtcNow;
+ if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
+ {
+ float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
+ if (moveDist > 0.05f)
+ _remoteMovementObservations[update.Guid] = (worldPos, now);
+ // else: leave old entry so "Time" = last real movement time
+ }
+ else
+ {
+ _remoteMovementObservations[update.Guid] = (worldPos, now);
+ }
+
+ // Retail-faithful hard-snap on UpdatePosition.
+ // Decompile: FUN_00559030 @ chunk_00550000.c:8232 writes
+ // pos/rot directly into PhysicsObj+0x80..0xBC with no blending.
+ // Between UpdatePositions, per-tick velocity integration keeps
+ // the rendered position close to server truth so each snap is
+ // small. When HasVelocity is set, we also seed PhysicsBody
+ // velocity (matches retail's set_velocity call in the same
+ // dispatcher).
+ if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState))
+ {
+ rmState = CreateRemoteMotion(update.Guid);
+ _remoteDeadReckon[update.Guid] = rmState;
+ // Hard-snap orientation on first spawn so the per-tick
+ // slerp doesn't visibly rotate from Identity to truth.
+ rmState.Body.Orientation = rot;
+ // #184 Slice 2b: PLACE the body at the server position on creation,
+ // mirroring the UM handler's seed (:5176 `Body.Position =
+ // entity.Position`). A UP-first RemoteMotion (created here before any
+ // UM) was left at the default (0,0,0). Path A never swept, so that
+ // stale origin was harmless — it caught up gradually. Now that Slice
+ // 2b runs the sweep for grounded PLAYERS too, an unplaced body would
+ // sweep from (0,0,0) in the server cell that does not contain it →
+ // garbage resolved pos → the digest's INVISIBLE/misplaced-body bug.
+ // Seeding here is the root-cause fix (the UP creation path should
+ // seed exactly like the UM path); worldPos == entity.Position (the
+ // unconditional snap at the top of this handler already ran).
+ rmState.Body.Position = worldPos;
+ }
+
+ // PositionPack::UnPack initializes an absent velocity to zero;
+ // MoveOrTeleport installs that exact vector with set_velocity.
+ // The canonical seam wakes the retained ObjectClock and body in
+ // one operation. Position-delta velocity below remains animation
+ // diagnostics and is never substituted into physics.
+ if (!_liveEntities.IsCurrentPositionAuthority(
+ positionRecord,
+ acceptedPositionAuthorityVersion))
+ {
+ return;
+ }
+ if (_liveEntities.IsCurrentVelocityAuthority(
+ positionRecord,
+ acceptedPositionVelocityAuthorityVersion)
+ && !_liveEntities.TryCommitAuthoritativeVelocity(
+ positionRecord,
+ rmState.Body,
+ acceptedSpawn.Physics?.Velocity
+ ?? System.Numerics.Vector3.Zero,
+ _physicsScriptGameTime))
+ {
+ return;
+ }
+
+ // Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a
+ // fresh TELEPORT_TS, or the first placement of a cell-less body,
+ // runs teleport_hook and SetPosition(0x1012) BEFORE the contact
+ // test. In particular, an airborne UP cannot veto or undo this
+ // authoritative destination. Do not pre-clear velocity or invent
+ // grounded flags here: retail SetPosition derives contact from its
+ // transition, while MoveOrTeleport does not consume arg5/arg6.
+ if (remotePlacementRequired)
+ {
+ double teleportTime =
+ (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ bool projectionVisible = _liveEntities.TryGetRecord(
+ update.Guid,
+ out LiveEntityRecord teleportRecord)
+ && teleportRecord.IsSpatiallyVisible;
+ var placement = _remoteTeleportController!.TryApply(
+ positionRecord,
+ acceptedPositionAuthorityVersion,
+ acceptedPositionVelocityAuthorityVersion,
+ rmState,
+ entity,
+ worldPos,
+ p.LandblockId,
+ new System.Numerics.Vector3(
+ p.PositionX,
+ p.PositionY,
+ p.PositionZ),
+ rot,
+ teleportTime,
+ projectionVisible,
+ acceptedSpawn.InstanceSequence,
+ acceptedSpawn.PositionSequence);
+ if (placement.Superseded
+ || !IsCurrentPositionOwner(entity))
+ {
+ return;
+ }
+ if (!placement.Applied)
+ {
+ entity.SetPosition(rmState.Body.Position);
+ entity.ParentCellId = rmState.CellId;
+ entity.Rotation = rmState.Body.Orientation;
+ if (rmState.CellId != 0)
+ _liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId);
+ return;
+ }
+
+ if (!IsCurrentPositionOwner(entity))
+ return;
+ entity.SetPosition(rmState.Body.Position);
+ entity.Rotation = rmState.Body.Orientation;
+ return;
+ }
+
+ // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
+ // player remotes. Mirrors CPhysicsObj::MoveOrTeleport
+ // (acclient @ 0x00516330) — airborne no-op, far-snap, near
+ // InterpolateTo. Gated on IsPlayerGuid so NPCs continue through
+ // the legacy synth-velocity branch below; their motion comes
+ // from ServerVelocity / ServerMoveTo which the legacy path
+ // already handles correctly.
+ //
+ if (IsPlayerGuid(update.Guid))
+ {
+ // InterpolationManager retains the complete target Position.
+ // A near correction replaces both origin and orientation via
+ // Position::subtract2; only placement/far branches snap here.
+ // Adopt server's cell ID on every UP (airborne or grounded).
+ // Required by the legacy airborne path's per-tick
+ // 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.
+ rmState.CellId = p.LandblockId;
+
+ // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
+ // server-pos snapshot forward AND print the per-UP comparison
+ // between the max literal CSequence root-motion speed observed
+ // since the last UP and the actual server broadcast pace. Both are sampled
+ // over the same window so the ratio reflects real overshoot.
+ {
+ double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
+ && rmState.LastServerPosTime > 0.0)
+ {
+ double dtServer = nowSecDiag - rmState.LastServerPosTime;
+ if (dtServer > 0.001)
+ {
+ var serverDelta = worldPos - rmState.LastServerPos;
+ float serverSpeed = (float)(serverDelta.Length() / dtServer);
+ float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP;
+ if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f)
+ {
+ System.Console.WriteLine(
+ $"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s "
+ + $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
+ + $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}");
+ }
+ }
+ }
+ rmState.MaxRootMotionSpeedSinceLastUP = 0f;
+ rmState.PrevServerPos = rmState.LastServerPos;
+ rmState.PrevServerPosTime = rmState.LastServerPosTime;
+ rmState.LastServerPos = worldPos;
+ 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.
+ 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
+ // teleport-to-server-then-yank-back rubber-band.
+ entity.SetPosition(rmState.Body.Position);
+ return;
+ }
+
+ // ── LANDING TRANSITION ────────────────────────────────────────
+ // First IsGrounded=true UP after rmState.Airborne signals landed.
+ // Clear airborne flags, hard-snap to authoritative landing position,
+ // clear interpolation queue (any pre-jump waypoints are stale).
+ if (rmState.Airborne)
+ {
+ rmState.Airborne = false;
+ rmState.Body.Velocity = System.Numerics.Vector3.Zero;
+ rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
+ | AcDream.Core.Physics.TransientStateFlags.OnWalkable;
+ rmState.Interp.Clear();
+ rmState.Body.Position = worldPos;
+ rmState.Body.Orientation = rot;
+
+ // #161: retail landing = MovementManager::HitGround
+ // (minterp → moveto, 0x00524300 — the R5-V5 facade
+ // relay) with the Gravity state bit STILL SET
+ // (CMotionInterp::HitGround gates on state&0x400). The
+ // re-apply dispatches the PRESERVED pre-fall forward
+ // command → landing link → cycle. This replaces the
+ // forced SetCycle, which read the then-clobbered
+ // ForwardCommand (Falling) and re-set the pose it meant
+ // to clear. See the twin block in TickAnimations
+ // (VU.land).
+ if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
+ && aeForLand.Sequencer is not null)
+ {
+ _motionRuntime.EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid);
+ }
+ ulong landingStateAuthorityVersion =
+ positionRecord.StateAuthorityVersion;
+ rmState.Movement.HitGround();
+ if (!IsCurrentPositionOwner(entity)
+ || !ReferenceEquals(
+ positionRecord.RemoteMotionRuntime,
+ rmState))
+ {
+ return;
+ }
+ // DR bookkeeping only (partner of the jump-start
+ // `State |= Gravity`).
+ if (_liveEntities.IsCurrentStateAuthority(
+ positionRecord,
+ landingStateAuthorityVersion))
+ {
+ rmState.Body.State &=
+ ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
+ }
+ return;
+ }
+
+ // ── 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)
+ {
+ // 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(
+ 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
+ // DELETED — retail never adapts a remote's animation from
+ // observed pace (deviation map DEV-2; premise refuted at
+ // decomp + ACE source + the S0 live capture, which shows
+ // explicit 0x0005↔0x0007 UMs on every Shift toggle).
+ // Player-remote cycles are UM-driven only.
+ if (rmState.PrevServerPosTime > 0.0)
+ {
+ double nowSecVel = rmState.LastServerPosTime;
+ double dtPos = nowSecVel - rmState.PrevServerPosTime;
+ if (dtPos > 0.001)
+ {
+ var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos;
+ rmState.ServerVelocity = synthVel;
+ rmState.HasServerVelocity = true;
+ }
+ }
+
+ // Sync the visible entity to the body — overrides the unconditional
+ // entity.SetPosition(worldPos) snap at the top of this function.
+ // For the far-snap branch this is a no-op (body == worldPos); for
+ // the near-enqueue branch this prevents a 1-frame teleport-then-
+ // yank-back rubber-band as TickAnimations chases worldPos via the
+ // queue.
+ //
+ // #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed
+ // body (mirrors the NPC UP-branch tail). Now that grounded players run
+ // the DR-tick sweep + shadow-follows-resolved, the retired raw-pos sync
+ // (top of this handler) would have re-snapped the shadow into overlap
+ // each UP; this keeps collision == render for the first UP (before any
+ // DR tick) and for no-Sequencer players. rmState.CellId is the server
+ // cell adopted above (:5735). The per-tick loop keeps it current
+ // between UPs (pose-gated). Commit the complete root before the
+ // publication gate, matching SetPositionInternal ordering.
+ entity.SetPosition(rmState.Body.Position);
+ entity.ParentCellId = rmState.CellId;
+ entity.Rotation = rmState.Body.Orientation;
+ AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
+ _liveEntities,
+ positionRecord,
+ entity,
+ rmState,
+ acceptedPositionAuthorityVersion,
+ () => _remotePhysicsUpdater.SyncRemoteShadowToBody(
+ entity.Id,
+ rmState,
+ _origin.CenterX,
+ _origin.CenterY));
+ return;
+ }
+
+ double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
+ System.Numerics.Vector3? serverVelocity = update.Velocity;
+ if (serverVelocity is null
+ && !IsPlayerGuid(update.Guid)
+ && rmState.LastServerPosTime > 0.0)
+ {
+ double elapsed = nowSec - rmState.LastServerPosTime;
+ if (elapsed > 0.001)
+ serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed;
+ }
+ if (serverVelocity is { } authoritativeVelocity)
+ {
+ rmState.ServerVelocity = authoritativeVelocity;
+ rmState.HasServerVelocity = true;
+ }
+ else if (!IsPlayerGuid(update.Guid))
+ {
+ rmState.ServerVelocity = System.Numerics.Vector3.Zero;
+ rmState.HasServerVelocity = false;
+ }
+ // R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping",
+ // stale facing, pushed-into-player): while an entity is STUCK,
+ // the sticky steer owns its frame — retail's UP corrections flow
+ // through the InterpolationManager into the SAME adjust_offset
+ // chain where StickyManager OVERWRITES them while armed
+ // (PositionManager::adjust_offset 0x00555190 order; sticky
+ // assigns m_fOrigin 0x00555430), so a server correction can
+ // never fight an armed stick frame-by-frame. This legacy NPC
+ // path hard-snaps OUTSIDE that chain, producing a visible
+ // snap-out/steer-back oscillation at UP cadence (position) and
+ // a stale-facing stomp (orientation). Faithful translation to
+ // the snap architecture: suppress the position/orientation/
+ // velocity snaps while stuck. LastServerPos/Time bookkeeping
+ // still records below — server truth reasserts on the first UP
+ // after unstick (bounded by the 1 s sticky lease). Register
+ // row with TS-41/TS-44.
+ bool snapSuppressedByStick =
+ (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
+ if (snapSuppressedByStick
+ && AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled)
+ {
+ float snapDist = System.Numerics.Vector3.Distance(
+ worldPos, rmState.Body.Position);
+ Console.WriteLine(FormattableString.Invariant(
+ $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
+ }
+ 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.
+ //
+ // 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
+ {
+ const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap
+ const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap
+ var localPlayerPosNpc = _playerController?.Position
+ ?? System.Numerics.Vector3.Zero;
+ 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);
+
+ if (firstUpNpc || !willBeDrTickedNpc
+ || distNpc > MaxPhysicsDistanceNpc
+ || bodyToTargetNpc > BodySnapThresholdNpc)
+ {
+ // Placement / far / large-correction: SNAP + clear queue.
+ rmState.Interp.Clear();
+ rmState.Body.Position = worldPos;
+ rmState.Body.Orientation = rot;
+ }
+ else
+ {
+ // Near DR correction: enqueue the waypoint for the per-tick
+ // catch-up (Path B consumes it via ComputeOffset).
+ 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;
+ }
+ }
+ }
+ // 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
+ // the FIRST UP after the jump, which:
+ // * restored Contact + OnWalkable,
+ // * removed the Gravity flag,
+ // * caused the next per-tick to stomp Velocity via
+ // apply_current_movement (reading InterpretedState =
+ // Ready, so Velocity.Z went to 0),
+ // …so the body got stuck at the server-broadcast apex Z,
+ // visibly hovering. The fix: leave Airborne true; the
+ // per-tick post-resolve logic detects an actual landing
+ // (resolveResult.IsOnGround && Velocity.Z <= 0) and clears
+ // it then. Mirrors how PlayerMovementController re-grounds
+ // the local player at the bottom of its arc.
+ //
+ // The position-snap above is still authoritative — if ACE
+ // says the body is at Z=68 mid-arc, we render Z=68. But we
+ // continue integrating gravity from there, so the body
+ // proceeds along the parabolic path between UPs.
+ // Adopt the server's cell ID as the transition starting cell.
+ // Retail authoritatively hard-snaps cell membership here too; our
+ // per-tick ResolveWithTransition sweep then advances CheckCellId
+ // as the sphere crosses cells and writes the new cell back into
+ // rmState.CellId so the NEXT frame starts in the correct cell.
+ rmState.CellId = p.LandblockId;
+
+ // Near UpdatePosition orientation is carried by the same complete
+ // interpolation Frame as translation. Placement, airborne, and
+ // far-correction branches above install the authoritative Frame
+ // directly. Sticky still receives the shared Frame afterward and
+ // may replace it while armed.
+ rmState.LastServerPos = worldPos;
+ rmState.LastServerPosTime = nowSec;
+
+ if (rmState.HasServerVelocity
+ && !snapSuppressedByStick
+ && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
+ {
+ // NPC/monster remotes: PlanFromVelocity cycle selection from
+ // UP-derived velocity (ACE broadcasts NPC motion patterns the
+ // UM stream alone doesn't cover). Player remotes return early
+ // inside — their cycles are UM-driven only per retail (L.2g
+ // S5; DEV-2 deleted). Unification of NPCs onto the
+ // CMotionInterp funnel is S6.
+ //
+ // D2 (Commit A 2026-05-03): tag whether the velocity feeding
+ // ApplyServerControlledVelocityCycle is wire-explicit or
+ // synthesized from position deltas (the common case).
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
+ {
+ string velSrc = update.Velocity is null ? "synth" : "wire";
+ System.Console.WriteLine(
+ $"[UPCYCLE_SRC] guid={update.Guid:X8} src={velSrc}");
+ }
+ RemoteServerControlledVelocityCycle.Apply(
+ update.Guid,
+ aeForVelocity,
+ rmState,
+ rmState.ServerVelocity);
+ }
+
+ // #184: sync the NPC shadow to the resolved/placed body (NOT the raw
+ // server pos — the raw-pos sync was RETIRED for players too in Slice 2b)
+ // so collision == render and the de-overlap isn't snapped away each UP.
+ // Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
+ // the per-tick loop skips). The per-tick loop keeps it current between
+ // UPs, pose-gated. rmState.CellId is the server cell adopted above.
+ // The root frame is committed before collision publication, as in
+ // retail SetPositionInternal.
+ entity.SetPosition(rmState.Body.Position);
+ entity.ParentCellId = rmState.CellId;
+ entity.Rotation = rmState.Body.Orientation;
+ AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
+ _liveEntities,
+ positionRecord,
+ entity,
+ rmState,
+ acceptedPositionAuthorityVersion,
+ () => _remotePhysicsUpdater.SyncRemoteShadowToBody(
+ entity.Id,
+ rmState,
+ _origin.CenterX,
+ _origin.CenterY));
+ }
+
+ // F751 is only a notification gate; the accepted Position may arrive
+ // before or after it. Canonical physics above always consumes the
+ // packet first. The presentation coordinator exposes exactly one
+ // sequence-correlated destination without reordering that state.
+ if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply
+ && update.Guid == _playerServerGuid
+ && _teleportTransit.OfferDestination(
+ update.TeleportSequence,
+ timestamps.TeleportAdvanced,
+ update,
+ out var teleportDestination))
+ {
+ _aimTeleportDestination(teleportDestination);
+ }
+ }
+
+ // Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
+ // player in PortalSpace until the destination is resident (TeleportWorldReady), then
+ // fires Place (materialize) and FireLoginComplete (regain control + ack the server).
+ // Replaces the old TeleportArrivalController hold/place machine.
+}
diff --git a/src/AcDream.App/Physics/LiveEntityVectorRoute.cs b/src/AcDream.App/Physics/LiveEntityVectorRoute.cs
new file mode 100644
index 00000000..9b81b733
--- /dev/null
+++ b/src/AcDream.App/Physics/LiveEntityVectorRoute.cs
@@ -0,0 +1,34 @@
+namespace AcDream.App.Physics;
+
+internal enum LiveEntityVectorRoute
+{
+ Projectile,
+ CanonicalBody,
+ OrdinaryRemote,
+}
+
+///
+/// Pins retail's one-CPhysicsObj vector-update priority. A missile owns its
+/// canonical body first; otherwise a retained static body consumes the wire
+/// vector; only an ordinary remote reaches MovementManager storage.
+///
+internal static class LiveEntityVectorRouter
+{
+ internal static LiveEntityVectorRoute Route(
+ Func tryProjectile,
+ Func tryCanonicalBody,
+ Action applyOrdinaryRemote)
+ {
+ ArgumentNullException.ThrowIfNull(tryProjectile);
+ ArgumentNullException.ThrowIfNull(tryCanonicalBody);
+ ArgumentNullException.ThrowIfNull(applyOrdinaryRemote);
+
+ if (tryProjectile())
+ return LiveEntityVectorRoute.Projectile;
+ if (tryCanonicalBody())
+ return LiveEntityVectorRoute.CanonicalBody;
+
+ applyOrdinaryRemote();
+ return LiveEntityVectorRoute.OrdinaryRemote;
+ }
+}
diff --git a/src/AcDream.App/Physics/LocalForcePositionTransaction.cs b/src/AcDream.App/Physics/LocalForcePositionTransaction.cs
new file mode 100644
index 00000000..a48f35fd
--- /dev/null
+++ b/src/AcDream.App/Physics/LocalForcePositionTransaction.cs
@@ -0,0 +1,29 @@
+namespace AcDream.App.Physics;
+
+///
+/// Preserves retail ForcePosition's atomic local order: validate the accepted
+/// Position authority, blip once, acknowledge once, then stop if the
+/// acknowledgement synchronously displaced that authority.
+///
+internal static class LocalForcePositionTransaction
+{
+ internal static bool Apply(
+ bool isForcePosition,
+ Func isCurrent,
+ Action blip,
+ Action acknowledge)
+ {
+ ArgumentNullException.ThrowIfNull(isCurrent);
+ ArgumentNullException.ThrowIfNull(blip);
+ ArgumentNullException.ThrowIfNull(acknowledge);
+
+ if (!isForcePosition)
+ return true;
+ if (!isCurrent())
+ return false;
+
+ blip();
+ acknowledge();
+ return isCurrent();
+ }
+}
diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs
index 412ef6e2..7837f878 100644
--- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs
+++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs
@@ -24,11 +24,10 @@ namespace AcDream.App.Physics;
/// the !IsPlayerGuid-gated stale-velocity animation-cycle stop. See
/// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md.
///
-/// Shared helpers that GameWindow also calls elsewhere are injected:
-/// GetSetupCylinder (a general Setup-dimension helper with ~9 callers,
-/// incl. the local player's own cylinder — kept on GameWindow) and
-/// ApplyServerControlledVelocityCycle (anim-cycle selection, also called
-/// from the UP handler) arrive as delegates. SyncRemoteShadowToBody
+/// Shared policy arrives through focused Physics delegates:
+/// GetSetupCylinder is owned by LiveEntityMotionRuntimeController,
+/// while ApplyServerControlledVelocityCycle is stateless policy also
+/// consumed by the UpdatePosition route. SyncRemoteShadowToBody
/// (remote-physics-specific) moved here and is called back from the UP-branch
/// tail; ApplyPositionManagerDelta / TickRemoteMoveTo had no other
/// callers and moved here outright.
diff --git a/src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs b/src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs
new file mode 100644
index 00000000..312e54c2
--- /dev/null
+++ b/src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs
@@ -0,0 +1,81 @@
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Physics;
+
+///
+/// Stateless AP-80 compatibility refinement for server-controlled non-player
+/// locomotion. Kept outside network routing because the per-frame remote
+/// physics updater and accepted Position tail consume the same operation.
+///
+internal static class RemoteServerControlledVelocityCycle
+{
+ private static bool IsPlayerGuid(uint guid) =>
+ (guid & 0xFF000000u) == 0x50000000u;
+ public static void Apply(
+ uint serverGuid,
+ LiveEntityAnimationState ae,
+ RemoteMotion rm,
+ System.Numerics.Vector3 velocity)
+ {
+ if (rm.Airborne) return;
+ if (ae.Sequencer is null) return;
+ // R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's
+ // get_command dispatch). Velocity-estimated cycle planning would
+ // fight it — same rationale as the pre-V4 ServerMoveToActive guard.
+ if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return;
+
+ if (IsPlayerGuid(serverGuid))
+ {
+ // L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO
+ // pace-derived cycle refinement — retail has no such mechanism
+ // anywhere in its inbound pipeline (deviation map DEV-2, two
+ // independent decomp dives + ACE cross-check). The #39-era
+ // premise ("retail's outbound goes silent on Shift toggle") was
+ // refuted at all three oracles + the S0 live capture: retail
+ // sends a fresh MoveToState on HoldRun toggle while moving
+ // (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE
+ // rebroadcasts every MoveToState (GameActionMoveToState.cs:36),
+ // and the wire shows explicit 0x0005↔0x0007 UMs on each toggle
+ // (launch-s0-wireprobe.log). The refinement layer's re-promote
+ // after legitimate flags=0 Ready UMs was itself the observed
+ // Ready↔Run thrash. Cycle changes for player remotes come from
+ // UpdateMotion ONLY, exactly like retail; position error is the
+ // InterpolationManager chase's job.
+ //
+ // NPC/monster remotes below keep PlanFromVelocity until S6
+ // unifies all entity classes onto the CMotionInterp funnel.
+ return;
+ }
+
+ uint currentMotion = ae.Sequencer.CurrentMotion;
+ // AP-80 is a position-only compatibility adaptation, not an
+ // animation authority. Restrict it to Ready/Walk/Run so late death
+ // position deltas cannot replace the authoritative Dead substate.
+ if (!AcDream.Core.Physics.ServerControlledLocomotion
+ .CanApplyVelocityCycle(currentMotion))
+ return;
+
+ var plan = AcDream.Core.Physics.ServerControlledLocomotion
+ .PlanFromVelocity(velocity);
+
+ uint style = ae.Sequencer.CurrentStyle != 0
+ ? ae.Sequencer.CurrentStyle
+ : 0x8000003Du;
+
+ // D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether
+ // ApplyServerControlledVelocityCycle is racing UpdateMotion-driven
+ // SetCycle for non-player remotes (NPCs / monsters).
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
+ {
+ System.Console.WriteLine(
+ $"[UPCYCLE] guid={serverGuid:X8} "
+ + $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) "
+ + $"|v|={velocity.Length():F2} "
+ + $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
+ + $"prev=0x{currentMotion:X8} "
+ + $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}");
+ }
+ ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
+ }
+
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5a088d91..2e522bc7 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -164,7 +164,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality =
AcDream.UI.Abstractions.Settings.QualitySettings.From(
AcDream.UI.Abstractions.Settings.QualityPreset.High);
- private uint? _lastLivePlayerLandblockId;
// Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
@@ -175,9 +174,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new();
// #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the
- // >10k-line TickAnimations (Code Structure Rule 1). Assigned in the ctor
- // once the injected shared helpers exist as method groups (GetSetupCylinder,
- // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
+ // >10k-line TickAnimations (Code Structure Rule 1). Reusable setup/motion
+ // policy is supplied through the once-bound live motion runtime, while the
+ // stateless server-velocity cycle remains a focused Physics helper.
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
private readonly AcDream.App.Physics.RemoteInboundMotionDispatcher
_remoteInboundMotion;
@@ -196,6 +195,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private AcDream.App.World.LiveEntityProjectionWithdrawalController?
_liveEntityProjectionWithdrawal;
private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration;
+ private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates;
+ private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings
+ _liveEntityMotionBindings = new();
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
@@ -390,14 +392,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// a Walk/Run command or from packet cadence.
///
///
- private readonly LiveEntityRemoteMotionRuntimeView _remoteDeadReckon;
///
/// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates,
/// keyed by server guid. Retail keeps these stamps on CPhysicsObj
/// (update_times); seeded from CreateObject's PhysicsDesc timestamp
/// block during , consulted at the
- /// top of , and dropped by the live
+ /// top of , and dropped by the live
/// entity teardown owner.
///
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
@@ -786,23 +787,25 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities);
- _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView(() => _liveEntities);
SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
- // #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's
- // PhysicsEngine; the two helpers it also needs but that have callers
- // outside the DR loop (GetSetupCylinder — the player's own cylinder +
- // moveto/sticky radii; ApplyServerControlledVelocityCycle — the UP
- // handler) stay on GameWindow and are injected as delegates.
+ // #184 Slice 2a: the extracted per-remote DR tick. Its stateful
+ // setup/motion dependency crosses the fail-fast construction bridge;
+ // the server-velocity animation cycle is stateless shared policy.
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
- _physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle);
+ _physicsEngine,
+ _liveEntityMotionBindings.GetSetupCylinder,
+ AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply);
_remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher(
- RouteServerMoveTo,
- StickToObjectFromWire);
+ (movement, cellId, update) =>
+ _liveEntityMotionBindings.RouteServerMoveTo(
+ movement, cellId, update),
+ (host, targetGuid) =>
+ _liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid));
var ordinaryPhysicsUpdater =
new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
_physicsEngine,
- GetSetupCylinder);
+ _liveEntityMotionBindings.GetSetupCylinder);
_liveAnimationScheduler = new LiveEntityAnimationScheduler(
() => _liveEntities,
() => _playerServerGuid,
@@ -2066,6 +2069,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// all guaranteed non-null here (startup throws above if any are missing).
var liveEntityComponentLifecycle =
new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle();
+ AcDream.App.Physics.LiveEntityMotionRuntimeController?
+ liveEntityMotionRuntime = null;
{
var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!);
// Sequencer factory: look up Setup + MotionTable from dats and build
@@ -2224,6 +2229,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
liveEntityComponentLifecycle);
+ liveEntityMotionRuntime =
+ new AcDream.App.Physics.LiveEntityMotionRuntimeController(
+ _liveEntities,
+ _physicsDataCache,
+ () => _selectionInteractions,
+ _selection,
+ _liveWorldOrigin);
+ _liveEntityMotionBindings.Bind(liveEntityMotionRuntime);
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
@@ -2306,7 +2319,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_physicsEngine.ShadowObjects,
entityEffects.PlayTypedFromHiddenTransition,
_equippedChildRenderer.SetDirectChildrenNoDraw,
- ClearTargetForHiddenEntity,
+ _liveEntityMotionBindings.ClearTargetForHiddenEntity,
() => (_liveCenterX, _liveCenterY),
handlePartArrayEnterWorld: localEntityId =>
{
@@ -2316,7 +2329,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
_physicsEngine,
_liveEntities,
- GetSetupCylinder,
+ _liveEntityMotionBindings.GetSetupCylinder,
CellLocalForSeed,
(entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id,
@@ -2361,7 +2374,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
player.CellId,
player.Position)
: null,
- GetSetupCylinder,
+ _liveEntityMotionBindings.GetSetupCylinder,
setupId =>
{
if (_dats is null)
@@ -2594,14 +2607,45 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
PublishLocalPhysicsTimestamps,
() => _playerServerGuid,
_options.DumpLiveSpawns ? Console.WriteLine : null);
+ _liveEntityNetworkUpdates =
+ new AcDream.App.Physics.LiveEntityNetworkUpdateController(
+ _liveEntities,
+ Objects,
+ _liveEntityHydration,
+ _entityEffects!,
+ _liveEntityPresentation!,
+ _liveEntityLights!,
+ _equippedChildRenderer!,
+ _projectileController!,
+ _remoteTeleportController!,
+ _animatedEntities,
+ new LiveEntityRemoteMotionRuntimeView(
+ () => _liveEntities),
+ _remoteMovementObservations,
+ _remotePhysicsUpdater,
+ _remoteInboundMotion,
+ liveEntityMotionRuntime!,
+ _physicsEngine,
+ _dats!,
+ _animLoader!,
+ _combatTargetController,
+ _liveWorldOrigin,
+ _teleportTransit,
+ () => _playerController,
+ _localPlayerOutbound,
+ () => _playerHost,
+ () => _playerServerGuid,
+ () => _physicsScriptGameTime,
+ () => LiveSession,
+ PublishLocalPhysicsTimestamps,
+ AimTeleportDestination,
+ DumpMovementTruthServerEcho);
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
_liveEntities,
() => _playerServerGuid,
candidate => _liveEntityHydration.OnPrune(candidate));
parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection);
- networkUpdateBridge.Bind(
- new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink(
- RouteSameGenerationCreateObject));
+ networkUpdateBridge.Bind(_liveEntityNetworkUpdates);
_equippedChildRenderer.EntityReady += candidate =>
_liveEntityHydration.OnEntityReady(candidate);
_liveEntityHydration.AppearanceApplied += guid =>
@@ -2759,7 +2803,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
_lastSeenRunSkill = -1;
_lastSeenJumpSkill = -1;
- _lastLivePlayerLandblockId = null;
+ _liveEntityNetworkUpdates?.ResetSessionState();
_liveEntityHydration?.ResetSessionState();
Shortcuts = Array.Empty();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
@@ -2853,13 +2897,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_liveEntityHydration!, pickup,
static (hydration, value) => hydration.OnPickup(value)),
MotionUpdated: motion => _inboundEntityEvents.Run(
- this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
+ _liveEntityNetworkUpdates!, motion,
+ static (updates, value) => updates.OnMotion(value)),
PositionUpdated: position => _inboundEntityEvents.Run(
- this, position, static (window, value) => window.OnLivePositionUpdated(value)),
+ _liveEntityNetworkUpdates!, position,
+ static (updates, value) => updates.OnPosition(value)),
VectorUpdated: vector => _inboundEntityEvents.Run(
- this, vector, static (window, value) => window.OnLiveVectorUpdated(value)),
+ _liveEntityNetworkUpdates!, vector,
+ static (updates, value) => updates.OnVector(value)),
StateUpdated: state => _inboundEntityEvents.Run(
- this, state, static (window, value) => window.OnLiveStateUpdated(value)),
+ _liveEntityNetworkUpdates!, state,
+ static (updates, value) => updates.OnState(value)),
ParentUpdated: parent => _inboundEntityEvents.Run(
_liveEntityHydration!, parent,
static (hydration, value) => hydration.OnParent(value)),
@@ -3141,37 +3189,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
doll.MeshRefs = reposed;
}
- private void RouteSameGenerationCreateObject(
- AcDream.App.World.SameGenerationCreateObjectEvents refresh)
- {
- if (_liveEntities!.TryGetEffectProfile(
- refresh.Appearance.Guid,
- out var effectProfile)
- && effectProfile is AcDream.App.Rendering.Vfx.EntityEffectProfile liveProfile)
- {
- liveProfile.ApplyNetworkDescription(refresh.Description);
- _entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
- }
-
- _liveEntityHydration!.OnAppearance(refresh.Appearance);
-
- if (refresh.Parent is { } parent)
- _liveEntityHydration.OnCreateParentAccepted(parent);
- else if (refresh.Position is { } position)
- {
- OnLivePositionUpdated(position);
- }
- else if (refresh.Pickup is { } pickup)
- {
- _liveEntityHydration.OnPickup(pickup);
- }
-
- if (refresh.Movement is { } movement)
- OnLiveMotionUpdated(movement);
- OnLiveStateUpdated(refresh.State);
- OnLiveVectorUpdated(refresh.Vector);
- }
-
private void PublishLocalPhysicsTimestamps(
uint guid,
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
@@ -3248,2145 +3265,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// InitializeMotionTables). Idempotent; safe from both the UM path and
/// the VectorUpdate path regardless of arrival order.
///
- private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
- RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid)
- {
- AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer;
- if (sequencer is not null && rm.Sink is null)
- {
- rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(sequencer);
- rm.Motion.DefaultSink = rm.Sink;
- }
- // #174 (2026-07-05): the RemoveLinkAnimations seam is retail
- // CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
- // CPartArray::HandleEnterWorld 0x00517d70 →
- // MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the
- // sequence's link animations AND drain pending_animations
- // completely (each pop fires MotionDone → CMotionInterp pops its
- // pending_motions node in lockstep). The previous binding was the
- // bare sequence strip — every LeaveGround (jump) stripped the
- // animations that queued manager nodes were counting down on,
- // orphaning them (NumAnims>0, anims gone) and permanently damming
- // BOTH queues: MotionsPending() never drained again, so every
- // armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
- // starved — the "door only works on a fresh session" bug.
- if (sequencer is not null)
- {
- rm.Motion.RemoveLinkAnimations = () => sequencer.Manager.HandleEnterWorld();
- rm.Motion.InitializeMotionTables = () => sequencer.Manager.InitializeState();
- // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
- // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
- rm.Motion.CheckForCompletedMotions = sequencer.Manager.CheckForCompletedMotions;
- }
-
- // Host/MoveTo ownership does not depend on a render animation. AP-77
- // deliberately supplies CMotionInterp's headless body fallback when a
- // live object has no PartArray sink, so build the manager once anyway.
- if (rm.Host is not null)
- return rm.Sink;
- if (_liveEntities is not { } liveEntities
- || !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord)
- || !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm))
- {
- return rm.Sink;
- }
- // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
- // wiring (the conformance-tested reference). Positions are WORLD
- // space on both sides (getPosition + the MovementStruct positions
- // the UM router builds) — one consistent space, so the manager's
- // distance/heading math matches the harness exactly.
- // R5-V5: the construction is now the MovementManager facade's
- // MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj
- // backpointers retail's MakeMoveToManager 0x00524000 constructs
- // from); MakeMoveToManager() below invokes it once, preserving the
- // pre-facade eager timing.
- var rmT = rm;
- var mtBody = rm.Body;
- // R5-V3 (#171): real setup cylsphere radii — retail CPartArray::
- // GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve
- // (a dictionary hit) so the read never races the spawn path's
- // CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math
- // (own side) and StickyManager::adjust_offset's own-radius gap term.
- // Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with
- // R5-V3").
- if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEntity))
- return rm.Sink;
- // R5-V2: forward-declared so the MoveToManager's target seams route
- // into the entity's TargetManager (retail CPhysicsObj::set_target →
- // TargetManager::SetTarget). Assigned right after the manager is built.
- EntityPhysicsHost host = null!;
- double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
- rm.Movement.MoveToFactory = () =>
- {
- var mtm = new AcDream.Core.Physics.Motion.MoveToManager(
- rm.Motion,
- stopCompletely: () => rmT.Movement.PerformMovement(
- new AcDream.Core.Physics.MovementStruct
- {
- Type = AcDream.Core.Physics.MovementType.StopCompletely,
- }),
- getPosition: () => new AcDream.Core.Physics.Position(
- rmT.CellId, mtBody.Position, mtBody.Orientation),
- getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
- mtBody.Orientation),
- setHeading: (h, _) => mtBody.Orientation =
- AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
- getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
- getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height,
- contact: () => mtBody.OnWalkable,
- isInterpolating: () => rmT.Interp.IsActive,
- getVelocity: () => mtBody.Velocity,
- getSelfId: () => serverGuid,
- // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum
- // → the entity's TargetManager (replaces the AP-79 TrackedTarget*
- // poll). The manager passes (0, top_level_id, 0.5, 0.0).
- setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q),
- clearTarget: () => host.ClearTarget(),
- getTargetQuantum: () => host.TargetManager.GetTargetQuantum(),
- setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q),
- // R4-V5: real clock (same epoch-seconds base the per-remote tick uses).
- curTime: NowSeconds);
- // R5-V3 (#171, retires TS-39): bind the sticky seams to the host's
- // PositionManager (host is constructed before MakeMoveToManager
- // invokes this factory) —
- // * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo
- // (retail MoveToManager::BeginNextNode @0x00529d3a);
- // * PerformMovement's head unstick: unstick_from_object →
- // PositionManager::UnStick (MoveToManager.PerformMovement:414).
- mtm.StickTo = (tlid, radius, height) =>
- host.PositionManager.StickTo(tlid, radius, height);
- mtm.Unstick = host.PositionManager.UnStick;
- return mtm;
- };
- // TS-36 (remote side): the interp's interrupt seam is retail's
- // interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
- // chain (V2's reentrancy tests prove the wiring is inert-safe;
- // R5-V5: the chain now lands on the literal facade relay 0x005241b0).
- rm.Motion.InterruptCurrentMovement =
- () => rmT.Movement.CancelMoveTo(
- AcDream.Core.Physics.WeenieError.ActionCancelled);
-
- // R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position
- // is the entity's WORLD position (WorldEntity.Position — exactly the
- // source the AP-79 poll used for a tracked target, so the voyeur system
- // delivers the identical position for a moveto's quantum-0 case).
- // HandleTargetting ticks per frame (added in the per-remote loop);
- // HandleUpdateTarget fans deliveries to this entity's MoveToManager
- // and (R5-V3, inside the host) its PositionManager — retail's
- // CPhysicsObj::HandleUpdateTarget order.
- var configuredHost = new EntityPhysicsHost(
- serverGuid,
- getPosition: () => new AcDream.Core.Physics.Position(
- hostRecord.FullCellId,
- hostRecord.WorldEntity?.Position ?? mtBody.Position,
- mtBody.Orientation),
- getVelocity: () => mtBody.Velocity,
- getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
- inContact: () => mtBody.OnWalkable,
- minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(),
- curTime: NowSeconds,
- physicsTimerTime: NowSeconds,
- getObjectA: ResolvePhysicsHost,
- // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
- // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
- // relay to the moveto side); the host chains the PositionManager
- // leg after it.
- handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
- interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
- AcDream.Core.Physics.WeenieError.ActionCancelled));
- host = EntityPhysicsHost.InstallOrRebind(
- liveEntities,
- hostRecord,
- configuredHost);
- rm.MarkFullPhysicsHostBound();
-
- // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
- // MoveToManager via the factory above (host now exists for the
- // sticky binds inside it). The UM funnel head's unstick
- // (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0
- // routing sites) binds on the interp beside it.
- rm.Movement.MakeMoveToManager();
- rm.Motion.UnstickFromObject = host.PositionManager.UnStick;
- return rm.Sink;
- }
-
- ///
- /// R5-V2 — retail CObjectMaint::GetObjectA(id): resolve any entity's
- /// by guid, the
- /// cross-entity seam every host's GetObjectA uses. If the entity has
- /// a bound host (from or
- /// EnterPlayerModeNow) return it; otherwise, for any entity that
- /// EXISTS in the world, lazily create a minimal position-only host. Retail
- /// gives every CPhysicsObj the capacity to host a
- /// target_manager — a STATIC object (chest, corpse) must still answer
- /// add_voyeur so a mover can moveto/stick to it (without this,
- /// auto-walk to a never-animated object would arm the moveto but never
- /// receive the immediate target snapshot, and never start). The minimal
- /// host is registered so subsequent lookups reuse it; it is NOT ticked
- /// (a static object never drifts, so the AddVoyeur immediate snapshot is
- /// all a watcher needs; despawn still fires ExitWorld via the registry).
- ///
- private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
- {
- if (_liveEntities is not { } liveEntities)
- return null;
-
- bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord);
- if (!isActive)
- return null;
-
- // TS-49: CObjCell::hide_object removes Hidden objects from the
- // relationship surface until DetectionManager is ported. Pending,
- // attached, and otherwise non-render-visible active objects remain in
- // CObjectMaint's object table and must still resolve here.
- if (liveEntities.IsHidden(id))
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- {
- Console.WriteLine(
- $"[autowalk-host-miss] object=0x{id:X8} "
- + $"materialized={_entitiesByServerGuid.ContainsKey(id)} "
- + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
- + $"hidden={liveEntities.IsHidden(id)}");
- }
- return null;
- }
- if (liveEntities.TryGetPhysicsHost(id, out var existing))
- return existing;
-
- double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
- var minimal = EntityPhysicsHost.CreateMinimal(
- activeRecord,
- ResolvePhysicsHost,
- NowSeconds);
- liveEntities.InstallPhysicsHost(activeRecord, minimal);
- return minimal;
- }
-
- ///
- /// R5-V3 (#171): retail CPartArray::GetRadius/GetHeight
- /// (0x005180a0/0x005180b0 — setup->radius/height × scale; ACE
- /// PartArray.cs:189-207 reads the same Setup-level fields). Returns
- /// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) —
- /// ACE's PartArray != null ? … : 0 fallback shape
- /// (PhysicsObj.cs:951-952). Live spawns cache their Setup in
- /// _physicsDataCache at spawn time (CacheSetup, ~3250), so this is
- /// a dictionary hit for every creature.
- ///
- private (float Radius, float Height) GetSetupCylinder(
- uint serverGuid, AcDream.Core.World.WorldEntity entity)
- {
- var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
- if (setup is null)
- return (0f, 0f);
- // Live spawns bake ObjScale into MeshRefs and never populate
- // WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the
- // authoritative per-entity scale is the SPAWN RECORD's ObjScale, the
- // same source the collision registration's entScale uses (~4137).
- // entity.Scale remains the fallback for non-spawn entities (scenery —
- // never a chase/sticky participant). Diff-review find: without this,
- // a server-scaled creature variant read unscaled radii and kept the
- // #171 interpenetration exactly for scaled bodies.
- float scale =
- LastSpawns.TryGetValue(serverGuid, out var sp)
- && sp.ObjScale is { } objScale && objScale > 0f
- ? objScale
- : (entity.Scale > 0f ? entity.Scale : 1f);
- return (setup.Radius * scale, setup.Height * scale);
- }
-
- // #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to
- // AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no
- // caller outside the DR tick; SyncRemoteShadowToBody is now called back via
- // _remotePhysicsUpdater from the NPC UP-branch tail (below).
-
- ///
- /// R5-V4: retail CPhysicsObj::stick_to_object (0x005127e0) — the
- /// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
- /// motionFlags 0x1 trailer guid): resolve the target, read its PartArray
- /// radius/height (0 when shapeless — retail's null-PartArray arm), then
- /// PositionManager::StickTo. Unresolvable target → no stick at
- /// all (retail's GetObjectA-null path). Retail resolves the target's
- /// top-level PARENT first (parent ?? self); acdream's entity
- /// table is flat (no client-side parenting), so the guid is used as-is —
- /// the same convention as RouteServerMoveTo's TopLevelId.
- ///
- private void StickToObjectFromWire(
- AcDream.Core.Physics.Motion.IPhysicsObjHost? host,
- uint targetGuid)
- {
- if (host is not EntityPhysicsHost entityHost)
- return;
- if (_liveEntities is not { } liveEntities
- || !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
- return;
- var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
- entityHost.PositionManager.StickTo(targetGuid, radius, height);
- }
-
- private void ClearTargetForHiddenEntity(uint serverGuid)
- {
- if (_selectionInteractions is { } interactions)
- interactions.OnEntityHidden(serverGuid);
- else if (_selection.SelectedObjectId == serverGuid)
- _selection.Clear(
- AcDream.Core.Selection.SelectionChangeSource.System,
- AcDream.Core.Selection.SelectionChangeReason.Cleared);
-
- if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true
- && hiddenHost is EntityPhysicsHost hiddenEntityHost)
- hiddenEntityHost.NotifyHidden();
- }
-
- ///
- /// R4-V5: shared retail unpack_movement type-6..9 routing
- /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes
- /// (R4-V4) and the local player (R4-V5). Writes the wire run rate to
- /// the interp (my_run_rate, unpack @300603/@300660 — plan M13),
- /// builds the MovementStruct (mt 6/8 resolve the target guid
- /// against the entity table; unresolvable degrades to
- /// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
- /// NOT an error), and calls the R5-V5 facade's PerformMovement
- /// (MovementManager::PerformMovement 0x005240d0 — types 6-9
- /// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager
- /// at each head, a no-op here since the bind sites create eagerly).
- /// Returns true when the event was a type-6..9 moveto (consumed); false
- /// for every other movement type (caller falls through to its funnel /
- /// skip posture).
- ///
- private bool RouteServerMoveTo(
- AcDream.Core.Physics.Motion.MovementManager movement,
- uint cellId,
- AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
- {
- if (update.MotionState.IsServerControlledMoveTo
- && update.MotionState.MoveToPath is { } path)
- {
- // my_run_rate write (unpack_movement @300603).
- if (update.MotionState.MoveToRunRate is { } mtRunRate)
- movement.Minterp.MyRunRate = mtRunRate;
-
- var destWorld = AcDream.Core.Physics.Motion.MoveToMath
- .OriginToWorld(
- path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
- _liveCenterX, _liveCenterY);
- var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
- path.Bitfield,
- path.DistanceToObject,
- path.MinDistance,
- path.FailDistance,
- update.MotionState.MoveToSpeed ?? 1f,
- path.WalkRunThreshold,
- path.DesiredHeading);
-
- var ms = new AcDream.Core.Physics.MovementStruct
- {
- Params = mp,
- };
- // mt 6 with a resolvable target → MoveToObject (the P4 tracker
- // feeds position updates per tick); else degrade to
- // MoveToPosition at the wire origin (§2f).
- if (update.MotionState.MovementType == 6
- && path.TargetGuid is { } tgtGuid
- && _liveEntities is { } liveMoveEntities
- && liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
- {
- ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
- ms.ObjectId = tgtGuid;
- ms.TopLevelId = tgtGuid;
- // R5-V3 (#171): retail resolves the TARGET object's PartArray
- // radius/height at the MoveToObject call site
- // (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE
- // PhysicsObj.cs:951-952) — they become SoughtObjectRadius/
- // Height, feeding GetCurrentDistance's edge-to-edge arrival
- // (UseSpheres) and the sticky-arrival handoff's target radius.
- // Was unset (0): every attacker closed ~one body-radius deeper
- // than retail before stopping — the #171 dogpile term.
- (ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt);
- ms.Pos = new AcDream.Core.Physics.Position(
- cellId, tgtEnt.Position,
- System.Numerics.Quaternion.Identity);
- }
- else
- {
- ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
- ms.Pos = new AcDream.Core.Physics.Position(
- cellId, destWorld,
- System.Numerics.Quaternion.Identity);
- }
- movement.PerformMovement(ms);
- return true;
- }
-
- if (update.MotionState.IsServerControlledTurnTo
- && update.MotionState.TurnToPath is { } turnPath)
- {
- var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
- turnPath.Bitfield,
- turnPath.Speed,
- turnPath.DesiredHeading);
-
- var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
- if (update.MotionState.MovementType == 8
- && turnPath.TargetGuid is { } turnTgt
- && _liveEntities is { } liveTurnEntities
- && liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
- {
- ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
- ms.ObjectId = turnTgt;
- ms.TopLevelId = turnTgt;
- ms.Pos = new AcDream.Core.Physics.Position(
- cellId, turnEnt.Position,
- System.Numerics.Quaternion.Identity);
- }
- else
- {
- ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
- // Retail's mt-8 unresolvable-object fallback substitutes the
- // STANDALONE wire heading into the params before degrading
- // (decomp §2f case 8: `params.desired_heading = wire_heading`).
- // Invisible against ACE (P6: both heading fields written from
- // the same source) but required for the verbatim degrade —
- // closes the V4 carry-over the adversarial review caught
- // (TurnToPathData.WireHeading was parsed but never consumed).
- if (update.MotionState.MovementType == 8
- && turnPath.WireHeading is { } wireHeading)
- {
- mp.DesiredHeading = wireHeading;
- }
- }
- movement.PerformMovement(ms);
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- {
- string target = turnPath.TargetGuid is { } targetGuid
- ? $"0x{targetGuid:X8}" : "null";
- bool targetVisible = turnPath.TargetGuid is { } visibleGuid
- && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid);
- bool targetHost = turnPath.TargetGuid is { } hostGuid
- && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
- var moveTo = movement.MoveTo;
- Console.WriteLine(
- $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} "
- + $"routed={ms.Type} target={target} visible={targetVisible} "
- + $"host={targetHost} stop={mp.StopCompletelyFlag} "
- + $"initialized={moveTo?.Initialized ?? false} "
- + $"nodes={moveTo?.PendingActions.Count() ?? 0} "
- + $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} "
- + $"pendingMotions={movement.Minterp.MotionsPending()}");
- }
- return true;
- }
-
- return false;
- }
-
- // #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime
- // 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is
- // gone, TargetManager voyeur delivers positions) moved to
- // RemotePhysicsUpdater. The DR tick was its only caller.
-
- ///
- /// Phase 6.6: the server says an entity's motion has changed. Look up
- /// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the
- /// new (stance, forward-command) override, and if the cycle is still
- /// animated, swap in the new animation/frame range. Entities not in
- /// the animated map (static props, entities rejected at spawn time)
- /// are simply ignored — there's nothing to tick for them.
- ///
- private void OnLiveMotionUpdated(AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
- {
- // L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation.
- // Retail drops stale/duplicate/superseded movement events at
- // DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) +
- // CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer +
- // SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
- // straggler re-applies an old gait or un-stops a stop.
- bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;
- if (!_liveEntities!.TryApplyMotion(
- update,
- retainPayload,
- out _,
- out var timestamps))
- {
- if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
- || Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
- {
- Console.WriteLine(
- $"[UM_STALE] guid={update.Guid:X8} inst={update.InstanceSequence} "
- + $"mov={update.MovementSequence} sc={update.ServerControlSequence} dropped");
- }
- return;
- }
-
- PublishLocalPhysicsTimestamps(update.Guid, timestamps);
-
- // R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous
- // gate (0x00509690 @0050972e, raw 271370-271431) — a movement event
- // whose wire autonomous byte is set is DROPPED ENTIRELY (no state
- // application, no interrupt) when the addressed object IsThePlayer.
- // ACE reflects the client's own outbound MoveToState back to the
- // sender with IsAutonomous=1 hardcoded (MovementData.cs:162 +
- // Player_Networking.cs:365) and retail never lets that echo reach
- // unpack_movement — which is what makes the unconditional
- // unpack-head interrupt in the player branch below safe against
- // ACE. Order matches retail: the sequence gates above run FIRST.
- // last_move_was_autonomous is NOT stored for dropped events (stored
- // only on the unpack path). This retires the row-less "don't cancel
- // on non-MoveTo UM" adaptation that lived here pre-V5 (its causal
- // story was stale — V0-pins.md P1). Run-rate sync is re-anchored to
- // retail's own feeds: PlayerDescription skills (SetCharacterSkills,
- // K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the
- // former ApplyServerRunRate echo tap is deleted, not gated.
- if (!retainPayload)
- return;
-
- if (!_liveEntities.TryGetRecord(
- update.Guid,
- out LiveEntityRecord acceptedMotionRecord))
- {
- return;
- }
- ulong acceptedMovementAuthorityVersion =
- acceptedMotionRecord.MovementAuthorityVersion;
- ulong acceptedMovementVelocityAuthorityVersion =
- acceptedMotionRecord.VelocityAuthorityVersion;
-
- if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
- if (!_animatedEntities.TryGetValue(entity.Id, out var ae))
- {
- DispatchRemoteInboundMotion(
- update,
- entity,
- ae: null,
- acceptedMotionRecord,
- acceptedMovementAuthorityVersion,
- acceptedMovementVelocityAuthorityVersion);
- return;
- }
- if (_dats is null) return;
-
- // Re-resolve using the new stance/command. Keep the setup and
- // motion-table we already know about — the server's motion
- // updates override state within the same table, not swap tables.
- //
- // IMPORTANT: stance and command are BOTH optional. Remote-player
- // autonomous broadcasts frequently set only one flag (e.g. just
- // ForwardCommand) with currentStyle=0x0000 meaning "no stance
- // change — keep current." Treating stance=0 as "default stance"
- // drops the real state; instead we preserve the sequencer's
- // current style.
- ushort stance = update.MotionState.Stance;
- ushort? command = update.MotionState.ForwardCommand;
-
- // A.1 (Commit A.1 2026-05-03): UM_RAW — every inbound UM, one line,
- // gated on ACDREAM_REMOTE_VEL_DIAG=1. Skips the local player. Tells
- // us the actual UM arrival rate per remote and which fields are set
- // on each. The bug-suspect is "ACE sends UMs without ForwardCommand
- // bit during running, our picker resolves to Ready, SetCycle(Ready)
- // resets the cycle". This diag lets us count how often that happens.
- if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
- && update.Guid != _playerServerGuid)
- {
- string cmdStrRaw = command.HasValue ? $"0x{command.Value:X4}" : "null";
- string sideStr = update.MotionState.SideStepCommand is { } s ? $"0x{s:X4}" : "null";
- string turnStr = update.MotionState.TurnCommand is { } t ? $"0x{t:X4}" : "null";
- string fwdSpdStr = update.MotionState.ForwardSpeed is { } fs ? $"{fs:F2}" : "null";
- uint seqMot = ae.Sequencer?.CurrentMotion ?? 0;
- System.Console.WriteLine(
- $"[UM_RAW] guid={update.Guid:X8} stance=0x{stance:X4} fwd={cmdStrRaw} fwdSpd={fwdSpdStr} "
- + $"side={sideStr} turn={turnStr} mt=0x{update.MotionState.MovementType:X2} "
- + $"isMoveTo={update.MotionState.IsServerControlledMoveTo} "
- + $"seq.CurrentMotion=0x{seqMot:X8}");
- }
-
- // Diagnostic: dump every inbound UpdateMotion so we can trace why
- // remote chars don't transition off RunForward when they stop.
- // Enable with ACDREAM_DUMP_MOTION=1.
- if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
- && update.Guid != _playerServerGuid)
- {
- string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
- float spd = update.MotionState.ForwardSpeed
- ?? ((update.MotionState.MoveToSpeed ?? 0f)
- * (update.MotionState.MoveToRunRate ?? 0f));
- uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
- uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
- Console.WriteLine(
- $"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
- $"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
- }
-
- // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation.
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
- && IsDoorName(Objects.Get(update.Guid)?.Name))
- {
- Console.WriteLine(System.FormattableString.Invariant(
- $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
- }
-
- // ── Sequencer path (preferred) ──────────────────────────────────
- // Call SetCycle directly. The sequencer already handles:
- // - left→right / backward→forward remapping via adjust_motion
- // - style and motion as u32 MotionCommand values
- // - fast-path for identical state
- //
- // When the server omits a field (stance flag not set, or command
- // flag not set), "no change" means we must preserve the sequencer's
- // current state, NOT fall back to a table default.
- if (ae.Sequencer is not null)
- {
- uint fullStyle = stance != 0
- ? (0x80000000u | (uint)stance)
- : ae.Sequencer.CurrentStyle;
-
- // ACE's stop signal: ForwardCommand flag CLEARED on the wire.
- // Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags,
- // when the player releases keys the InterpretedMotionState has
- // ForwardCommand = Invalid (default) and BuildMovementFlags doesn't
- // set bit 0x02 — so the field is absent. Retail's decompiled
- // handler (FUN_005295D0 → FUN_0051F260 @ chunk_00510000.c:13957)
- // bulk-copies Invalid/0 into the physics obj, which StopCompletely
- // treats as "return to style default (Ready)."
- //
- // command == null → retail stop signal → Ready
- // command.Value == 0 → explicit 0 (rare) → Ready
- // otherwise → resolve class byte and use full cmd
- float speedMod = update.MotionState.ForwardSpeed ?? 1f;
- uint fullMotion;
- // R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no
- // longer flow through the interpreted funnel at all (retail
- // unpack_movement routes types 6-9 to MoveToManager; only type
- // 0 does the interpreted-state copy). The manager's own
- // BeginMoveForward -> get_command -> _DoMotion produces the
- // cycle through the same sink every other motion uses.
- if (!command.HasValue || command.Value == 0)
- {
- fullMotion = 0x41000003u;
- }
- else
- {
- // Use MotionCommandResolver to restore the proper class
- // byte from the wire's 16-bit ForwardCommand.
- uint resolved = AcDream.Core.Physics.MotionCommandResolver
- .ReconstructFullCommand(command.Value);
- fullMotion = resolved != 0
- ? resolved
- : (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value;
- if (fullMotion == (uint)command.Value) // no class bits yet
- fullMotion = 0x40000000u | (uint)command.Value;
- }
-
- // ForwardSpeed from the InterpretedMotionState (flag 0x04).
- // ACE omits this field when speed == 1.0 (only sets the flag
- // when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101).
- // So:
- // - field absent → default 1.0 (normal speed)
- // - field present → USE THE VALUE, including zero.
- //
- // Zero is a VALID stop signal: when the retail client releases
- // W, ACE broadcasts WalkForward with ForwardSpeed=0 (via
- // apply_run_to_command). Treating zero as "unspecified / 1.0"
- // produces "slow walk that never stops" — exactly what the
- // stop bug looked like.
- if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
- && update.Guid != _playerServerGuid)
- Console.WriteLine(
- $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})");
-
- // No-op if same; the sequencer's fast path guards against that.
- uint priorMotion = ae.Sequencer.CurrentMotion;
-
- // The SetObjectMovement gate above already rejects local
- // autonomous echoes. A local event reaching this point is
- // server-authored, so retail applies it through the interpreted
- // funnel. This is especially important for attacks: ACE chooses
- // the exact swing and carries it in Commands[].
- if (update.Guid == _playerServerGuid)
- {
- // B.6 slice 1 (2026-05-14): trace inbound motion for the
- // local player. One line per inbound UM, gated on
- // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- {
- string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null";
- string pathStr = update.MotionState.MoveToPath is { } p
- ? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}"
- : "path=null";
- string spd = update.MotionState.ForwardSpeed is { } fs
- ? $"fwdSpd={fs:F2}"
- : "fwdSpd=null";
- string mtsSpd = update.MotionState.MoveToSpeed is { } ms
- ? $"mtSpd={ms:F2}"
- : "mtSpd=null";
- string mtsRun = update.MotionState.MoveToRunRate is { } mr
- ? $"mtRun={mr:F2}"
- : "mtRun=null";
- Console.WriteLine(System.FormattableString.Invariant(
- $"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
- }
-
- // R4-V5: retail unpack_movement dispatch for the local
- // player — the SAME shape the remote branch uses below.
- // Head (@300566): interrupt + unstick fire for EVERY
- // movement event that reached unpack (the P1 gate above
- // already dropped the autonomous echoes that would have
- // made this unsafe against ACE); then types 6-9 route to
- // the player's MoveToManager. mt-0 falls through to the
- // interpreted-state copy below; LastMoveWasAutonomous=false
- // is the local equivalent of LoseControlToServer until the
- // next user-input edge takes control back.
- if (_playerController is not null)
- {
- // P1 tail (00509730): the unpack path stores the wire
- // autonomous byte BEFORE unpack_movement — always false
- // here (the gate above dropped autonomous events). This
- // is what routes the controller's per-tick pump (A3
- // dual dispatch) to the INTERPRETED branch during a
- // server moveto. LOCAL PLAYER ONLY for now: remotes'
- // interps have no WeenieObj, which A3 treats as
- // IsThePlayer — storing a remote player's autonomous
- // byte would flip their per-tick apply onto the raw
- // branch and clobber their funnel state; the remote
- // store lands with real remote weenies (R5+).
- _playerController.SetLastMoveWasAutonomous(update.IsAutonomous);
- bool IsCurrentLocalMotion() =>
- _liveEntities.IsCurrentMovementAuthority(
- acceptedMotionRecord,
- acceptedMovementAuthorityVersion)
- && _liveEntities.IsCurrentVelocityAuthority(
- acceptedMotionRecord,
- acceptedMovementVelocityAuthorityVersion)
- && ReferenceEquals(
- acceptedMotionRecord.WorldEntity,
- entity);
- if (!IsCurrentLocalMotion())
- return;
-
- // Local and remote packets now share the literal
- // MovementManager::unpack_movement funnel. Besides
- // removing duplicate retail ordering, the authority
- // predicate is rechecked after every callback boundary;
- // a nested newer packet can never be overwritten by the
- // tail of this older one.
- AcDream.App.Physics.RemoteInboundMotionDispatchResult localDispatch =
- _remoteInboundMotion.Apply(
- update,
- _playerController.Movement,
- _playerController.Motion.DefaultSink,
- _playerHost,
- _playerController.CellId,
- ae.Sequencer.CurrentMotion & 0xFF000000u,
- IsCurrentLocalMotion);
- if (localDispatch.Superseded
- || !IsCurrentLocalMotion())
- {
- return;
- }
- if (localDispatch.RoutedMoveTo)
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- {
- Console.WriteLine(System.FormattableString.Invariant(
- $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}"));
- }
- return;
- }
- if (!localDispatch.AppliedInterpretedState)
- return;
- fullMotion = localDispatch.CurrentForwardCommand;
- }
- }
- else
- {
- // One packet owner handles both PartArray-backed remotes and
- // AP-77's animation-less body fallback. GameWindow performs
- // only live-owner lookup and supplies the optional sink.
- AcDream.App.Physics.RemoteInboundMotionDispatchResult dispatch =
- DispatchRemoteInboundMotion(
- update,
- entity,
- ae,
- acceptedMotionRecord,
- acceptedMovementAuthorityVersion,
- acceptedMovementVelocityAuthorityVersion);
- if (dispatch.Superseded
- || dispatch.RoutedMoveTo
- || !dispatch.AppliedInterpretedState)
- return;
- fullMotion = dispatch.CurrentForwardCommand;
- }
-
- // Authoritative Dead motion invalidates a selected combat target.
- // The controller clears shared selection, whose SelectionChanged
- // consumer ports retail's post-clear AutoTarget behavior.
- _combatTargetController?.OnMotionApplied(
- update.Guid, ae.Sequencer.CurrentMotion);
- if (!_liveEntities.IsCurrentMovementAuthority(
- acceptedMotionRecord,
- acceptedMovementAuthorityVersion)
- || !_liveEntities.IsCurrentVelocityAuthority(
- acceptedMotionRecord,
- acceptedMovementVelocityAuthorityVersion))
- {
- return;
- }
-
- // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
- // stamp the remote observation timestamp to "now". Without this,
- // the stop-detection loop in TickAnimations sees the previous
- // observation timestamp (set by the last UpdatePosition,
- // often >300ms ago during idle) and fires the stop signal
- // IMMEDIATELY — flipping the sequencer straight back to Ready.
- // The visible symptom was "remote char never animates; just
- // stands there, teleporting position every UpdatePosition."
- // Fresh timestamp gives the stop-timer a full 300ms window to
- // observe genuine position stagnation before reverting.
- uint newLo = fullMotion & 0xFFu;
- bool enteringLocomotion = newLo == 0x05 || newLo == 0x06
- || newLo == 0x07
- || newLo == 0x0F || newLo == 0x10;
- uint oldLo = priorMotion & 0xFFu;
- bool wasLocomotion = oldLo == 0x05 || oldLo == 0x06
- || oldLo == 0x07
- || oldLo == 0x0F || oldLo == 0x10;
- if (enteringLocomotion && !wasLocomotion && update.Guid != _playerServerGuid)
- {
- // Reset both stop signals so stop-detection starts a fresh
- // window from this transition. Without this, the entity
- // starts its run animation and is instantly interrupted.
- var refreshedTime = System.DateTime.UtcNow;
- if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
- _remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime);
- if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr))
- dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
- }
-
- return;
- }
-
- // ── Legacy path (entities without a sequencer) ──────────────────
- // Here we DO use GetIdleCycle because the legacy tick loop needs
- // a concrete Animation + frame range. Only swap when the resolver
- // returns a clearly-better cycle.
- var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
- ae.Setup, _dats, _animLoader!,
- motionTableIdOverride: null,
- stanceOverride: stance,
- commandOverride: command);
- bool newCycleIsGood = newCycle is not null
- && newCycle.Framerate != 0f
- && newCycle.HighFrame >= newCycle.LowFrame
- && newCycle.Animation.PartFrames.Count >= 1;
- if (!newCycleIsGood) return;
-
- ae.Animation = newCycle!.Animation;
- ae.LowFrame = Math.Max(0, newCycle.LowFrame);
- ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
- ae.Framerate = newCycle.Framerate;
- ae.CurrFrame = ae.LowFrame;
- }
-
- ///
- /// Resolves one live remote owner and delegates retail's entire
- /// unpack_movement body to the shared animation-optional packet
- /// dispatcher. The render PartArray contributes only its optional sink.
- ///
- private AcDream.App.Physics.RemoteInboundMotionDispatchResult
- DispatchRemoteInboundMotion(
- AcDream.Core.Net.WorldSession.EntityMotionUpdate update,
- AcDream.Core.World.WorldEntity entity,
- LiveEntityAnimationState? ae,
- LiveEntityRecord acceptedRecord,
- ulong acceptedMovementAuthorityVersion,
- ulong acceptedVelocityAuthorityVersion)
- {
- if (update.Guid == _playerServerGuid)
- return default;
-
- bool IsCurrentOwner(RemoteMotion? expectedRemote = null) =>
- _liveEntities is { } live
- && live.IsCurrentMovementAuthority(
- acceptedRecord,
- acceptedMovementAuthorityVersion)
- && live.IsCurrentVelocityAuthority(
- acceptedRecord,
- acceptedVelocityAuthorityVersion)
- && ReferenceEquals(acceptedRecord.WorldEntity, entity)
- && (ae is null
- ? acceptedRecord.AnimationRuntime is null
- : ReferenceEquals(acceptedRecord.AnimationRuntime, ae))
- && (expectedRemote is null
- || ReferenceEquals(
- acceptedRecord.RemoteMotionRuntime,
- expectedRemote));
- if (!IsCurrentOwner())
- return default;
-
- if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remote))
- {
- remote = CreateRemoteMotion(update.Guid);
- remote.Body.Orientation = entity.Rotation;
- remote.Body.Position = entity.Position;
- _remoteDeadReckon[update.Guid] = remote;
- }
- if (!IsCurrentOwner(remote))
- return default;
-
- var sink = EnsureRemoteMotionBindings(remote, ae, update.Guid);
- uint commandClass = ae?.Sequencer?.CurrentMotion & 0xFF000000u
- ?? remote.Motion.InterpretedState.ForwardCommand & 0xFF000000u;
- if (commandClass == 0u)
- commandClass = 0x41000000u;
-
- AcDream.App.Physics.RemoteInboundMotionDispatchResult result =
- _remoteInboundMotion.Apply(
- update,
- remote.Movement,
- sink,
- remote.Host,
- remote.CellId,
- commandClass,
- () => IsCurrentOwner(remote));
-
- if (result.Superseded || !IsCurrentOwner(remote))
- return result with { Superseded = true };
-
- if (result.ForwardCommandChanged)
- {
- if (System.Environment.GetEnvironmentVariable(
- "ACDREAM_REMOTE_VEL_DIAG") == "1")
- {
- System.Console.WriteLine(
- $"[FWD_WIRE] guid={update.Guid:X8} "
- + $"oldCmd=0x{result.PreviousForwardCommand:X8} "
- + $"newCmd=0x{result.CurrentForwardCommand:X8} "
- + $"newLow=0x{result.CurrentForwardCommand & 0xFFu:X2} "
- + $"speed={update.MotionState.ForwardSpeed ?? 1f:F3}");
- }
- remote.PrevServerPosTime = 0.0;
- }
-
- if (result.AppliedInterpretedState && ae is null)
- {
- _combatTargetController?.OnMotionApplied(
- update.Guid,
- result.CurrentForwardCommand);
- if (!IsCurrentOwner(remote))
- return result with { Superseded = true };
- }
- return result;
- }
-
- ///
- /// Phase 6.7: the server says an entity moved. Translate its new
- /// landblock-local position into acdream world space (same math as
- /// CreateObject hydration) and update the entity's Position/Rotation
- /// in place so the next Draw picks up the new transform.
- ///
- /// Phase B.3 extension: if the player controller is in PortalSpace and
- /// this update is for our own character, detect a large position change
- /// (different landblock or > 100 units distance). If detected, recenter
- /// the streaming controller, resolve the new position through physics,
- /// snap the player entity + controller, and return to InWorld. Also sends
- /// LoginComplete so the server knows the client has loaded the destination.
- ///
- ///
- /// Reports whether the exact remote component currently belongs to the
- /// visible ordinary-object workset that consumes interpolation targets.
- ///
- private bool WillAdvanceRemoteMotion(uint serverGuid, RemoteMotion remote)
- {
- return _liveEntities is { } runtime
- && runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)
- && ReferenceEquals(record.RemoteMotionRuntime, remote)
- && (record.FinalPhysicsState
- & AcDream.Core.Physics.PhysicsStateFlags.Static) == 0
- && runtime.GetRootObjectClockDisposition(serverGuid)
- is AcDream.Core.Physics.RetailObjectClockDisposition.Advance
- && runtime.IsCurrentSpatialRemoteMotion(record, remote);
- }
-
- ///
- /// Creates the optional MovementManager companion for one live object.
- /// A retained projectile or static-animation owner contributes the
- /// already-owned CPhysicsObj body; an ordinary remote gets local storage.
- ///
- private RemoteMotion CreateRemoteMotion(uint serverGuid)
- {
- LiveEntityRecord? record = null;
- if (_liveEntities is { } liveEntities
- && liveEntities.TryGetRecord(serverGuid, out var currentRecord))
- {
- record = currentRecord;
- }
-
- RemoteMotion remote;
- if (record?.PhysicsBody is { } canonicalBody)
- {
- // Retail has one CPhysicsObj. A projectile or Physics-Static
- // animation workset can create it before the MovementManager;
- // adopt its current frame/vectors without replaying CreateObject.
- remote = new RemoteMotion(canonicalBody);
- }
- else
- {
- remote = new RemoteMotion();
- if (record is not null)
- AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize(
- remote.Body,
- record);
- }
-
- RemoteMotion incarnation = remote;
- remote.Movement.ActivatePhysicsObject = () =>
- _liveEntities?.TryActivateOrdinaryObject(serverGuid, incarnation);
- return remote;
- }
-
- ///
- /// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps.
- /// The payload seeds the world-space launch velocity and angular velocity.
- ///
- private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
- {
- if (_projectileController?.CanAcceptVectorPayload(
- update.Guid,
- update.Velocity,
- update.Omega) == false)
- return;
- if (!_liveEntities!.TryApplyVector(update, out _)) return;
- if (!_liveEntities.TryGetRecord(
- update.Guid,
- out LiveEntityRecord acceptedVectorRecord))
- {
- return;
- }
- ulong acceptedVectorAuthorityVersion =
- acceptedVectorRecord.VectorAuthorityVersion;
- ulong acceptedVectorVelocityAuthorityVersion =
- acceptedVectorRecord.VelocityAuthorityVersion;
-
- if (_projectileController?.ApplyAuthoritativeVector(
- acceptedVectorRecord,
- acceptedVectorAuthorityVersion,
- acceptedVectorVelocityAuthorityVersion,
- update.Velocity,
- update.Omega,
- _physicsScriptGameTime) == true)
- return;
-
- // A Physics-Static animation owner can own the canonical CPhysicsObj
- // before any MovementManager exists. F74E still writes directly to
- // that object's vectors; do not manufacture a remote-motion runtime
- // merely to keep animate_static_object's omega current.
- if (update.Guid != _playerServerGuid
- && acceptedVectorRecord.RemoteMotionRuntime is null
- && acceptedVectorRecord.PhysicsBody is { } canonicalBody)
- {
- _liveEntities.TryCommitAuthoritativeVector(
- acceptedVectorRecord,
- canonicalBody,
- update.Velocity,
- update.Omega,
- _physicsScriptGameTime);
- return;
- }
-
- if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
-
- if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
- if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return;
- LiveEntityRecord remoteRecord = acceptedVectorRecord;
-
- // World-space velocity. Apply directly to the body — the per-tick
- // remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt².
- // L.3.1 Task 6: apply Omega too. LiveEntityRuntime commits both
- // writes to the one canonical CPhysicsObj and wakes its retained
- // update_time clock on the same non-Static edge.
- if (!_liveEntities.TryCommitAuthoritativeVector(
- remoteRecord,
- rm.Body,
- update.Velocity,
- update.Omega,
- _physicsScriptGameTime))
- {
- return;
- }
-
- // Mark airborne when the launch has meaningful +Z. Threshold
- // 0.5 m/s rejects noise / horizontal-only updates (server might
- // also use VectorUpdate for non-jump events). The per-tick
- // remote update reads .Airborne to skip the ground-clamp branch
- // and apply gravity instead.
- if (update.Velocity.Z > 0.5f)
- {
- rm.Airborne = true;
- // Clear ground-contact bits + enable gravity so calc_acceleration
- // returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then
- // produces the parabolic arc.
- rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact
- | AcDream.Core.Physics.TransientStateFlags.OnWalkable);
- rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
-
- // R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism.
- // The remote's ground departure fires LeaveGround (0x00528b00):
- // strips pending transition links (the RemoveLinkAnimations
- // seam) + re-applies movement through DefaultSink, whose
- // contact-gated funnel dispatch engages Falling — no forced
- // SetCycle, no skip flag. The wire velocity/omega are re-applied
- // AFTER so they stay authoritative over LeaveGround's
- // state-derived velocity write (adaptation note: retail's
- // equivalence comes from the per-tick transition-sweep order —
- // R6 scope).
- if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent)
- && _animatedEntities.TryGetValue(ent.Id, out var ae)
- && ae.Sequencer is not null)
- {
- EnsureRemoteMotionBindings(rm, ae, update.Guid);
- rm.Motion.LeaveGround();
- if (!_liveEntities.IsCurrentVectorAuthority(
- remoteRecord,
- acceptedVectorAuthorityVersion)
- || !_liveEntities.IsCurrentVelocityAuthority(
- remoteRecord,
- acceptedVectorVelocityAuthorityVersion)
- || !_liveEntities.TryCommitAuthoritativeVector(
- remoteRecord,
- rm.Body,
- update.Velocity,
- update.Omega,
- _physicsScriptGameTime))
- {
- return;
- }
- }
- }
-
- if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
- {
- Console.WriteLine(
- $"VU guid=0x{update.Guid:X8} vel=({update.Velocity.X:F2},{update.Velocity.Y:F2},{update.Velocity.Z:F2}) airborne={rm.Airborne}");
- }
- }
-
- ///
- /// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the
- /// new PhysicsState bits into ShadowObjectRegistry so the
- /// existing check honors
- /// the flip on the next resolver tick. Chiefly doors:
- /// server flips ETHEREAL_PS = 0x4 on Use, the door's
- /// cylinder collision stops blocking the threshold.
- ///
- private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
- {
- if (!_liveEntities!.TryApplyState(parsed, out _, out _)) return;
-
- if (!_liveEntities.TryGetRecord(parsed.Guid, out LiveEntityRecord record))
- return;
- ulong acceptedStateAuthorityVersion = record.StateAuthorityVersion;
-
- // Retail set_state order: Lighting, NoDraw, then Hidden. The live
- // runtime already committed the raw/final bits and draw visibility;
- // apply the ordered owners before updating motion/collision consumers.
- _liveEntityLights?.OnStateChanged(parsed.Guid);
- _liveEntityPresentation?.OnStateAccepted(parsed.Guid);
-
- if (!_liveEntities.IsCurrentStateAuthority(
- record,
- acceptedStateAuthorityVersion))
- {
- return;
- }
-
- _projectileController?.ApplyAuthoritativeState(
- record,
- acceptedStateAuthorityVersion,
- record.FinalPhysicsState,
- _physicsScriptGameTime,
- _liveCenterX,
- _liveCenterY);
- if (!_liveEntities.IsCurrentStateAuthority(
- record,
- acceptedStateAuthorityVersion))
- {
- return;
- }
- if (parsed.Guid == _playerServerGuid)
- _playerController?.ApplyPhysicsState(record.FinalPhysicsState);
-
- if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
-
- // L.2g slice 1c (2026-05-13): the server addresses entities by
- // ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but
- // ShadowObjectRegistry's cell index is keyed by local entity.Id
- // (e.g. 0x000F4245). Translate via _entitiesByServerGuid before
- // mutating the registry — otherwise the lookup misses and the
- // state flip silently no-ops, leaving doors blocked even though
- // ACE flipped the ETHEREAL bit.
- uint registryKey = entity.Id;
-
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
- Console.WriteLine(System.FormattableString.Invariant(
- $"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
- }
-
- private void ApplyServerControlledVelocityCycle(
- uint serverGuid,
- LiveEntityAnimationState ae,
- RemoteMotion rm,
- System.Numerics.Vector3 velocity)
- {
- if (rm.Airborne) return;
- if (ae.Sequencer is null) return;
- // R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's
- // get_command dispatch). Velocity-estimated cycle planning would
- // fight it — same rationale as the pre-V4 ServerMoveToActive guard.
- if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return;
-
- if (IsPlayerGuid(serverGuid))
- {
- // L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO
- // pace-derived cycle refinement — retail has no such mechanism
- // anywhere in its inbound pipeline (deviation map DEV-2, two
- // independent decomp dives + ACE cross-check). The #39-era
- // premise ("retail's outbound goes silent on Shift toggle") was
- // refuted at all three oracles + the S0 live capture: retail
- // sends a fresh MoveToState on HoldRun toggle while moving
- // (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE
- // rebroadcasts every MoveToState (GameActionMoveToState.cs:36),
- // and the wire shows explicit 0x0005↔0x0007 UMs on each toggle
- // (launch-s0-wireprobe.log). The refinement layer's re-promote
- // after legitimate flags=0 Ready UMs was itself the observed
- // Ready↔Run thrash. Cycle changes for player remotes come from
- // UpdateMotion ONLY, exactly like retail; position error is the
- // InterpolationManager chase's job.
- //
- // NPC/monster remotes below keep PlanFromVelocity until S6
- // unifies all entity classes onto the CMotionInterp funnel.
- return;
- }
-
- uint currentMotion = ae.Sequencer.CurrentMotion;
- // AP-80 is a position-only compatibility adaptation, not an
- // animation authority. Restrict it to Ready/Walk/Run so late death
- // position deltas cannot replace the authoritative Dead substate.
- if (!AcDream.Core.Physics.ServerControlledLocomotion
- .CanApplyVelocityCycle(currentMotion))
- return;
-
- var plan = AcDream.Core.Physics.ServerControlledLocomotion
- .PlanFromVelocity(velocity);
-
- uint style = ae.Sequencer.CurrentStyle != 0
- ? ae.Sequencer.CurrentStyle
- : 0x8000003Du;
-
- // D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether
- // ApplyServerControlledVelocityCycle is racing UpdateMotion-driven
- // SetCycle for non-player remotes (NPCs / monsters).
- if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
- {
- System.Console.WriteLine(
- $"[UPCYCLE] guid={serverGuid:X8} "
- + $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) "
- + $"|v|={velocity.Length():F2} "
- + $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
- + $"prev=0x{currentMotion:X8} "
- + $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}");
- }
- ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
- }
-
- ///
- /// Retail's local FORCE_POSITION branch calls SendPositionEvent
- /// immediately after BlipPlayer. This acknowledgement uses the accepted
- /// timestamps and preserves the player's current heading.
- ///
- private void SendImmediateLocalPositionEvent()
- {
- AcDream.Core.Net.WorldSession? session = LiveSession;
- if (session is null
- || _playerController is null
- || !_playerController.CanSendPositionEvent)
- {
- return;
- }
-
- AcDream.Core.Physics.Position canonical = _playerController.CellPosition;
- uint cellId = canonical.ObjCellId;
- System.Numerics.Vector3 position = canonical.Frame.Origin;
- System.Numerics.Quaternion rotation = _playerController.BodyOrientation;
- if (!AcDream.Core.Physics.PositionFrameValidation.IsValid(
- cellId, position, rotation))
- {
- return;
- }
-
- uint sequence = session.NextGameActionSequence();
- var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
- gameActionSequence: sequence,
- cellId: cellId,
- position: position,
- rotation: rotation,
- instanceSequence: session.InstanceSequence,
- serverControlSequence: session.ServerControlSequence,
- teleportSequence: session.TeleportSequence,
- forcePositionSequence: session.ForcePositionSequence,
- lastContact: 1);
- session.SendGameAction(body);
- _playerController.NotePositionSent(
- new AcDream.Core.Physics.Position(cellId, position, rotation),
- _playerController.ContactPlane,
- _playerController.SimTimeSeconds);
- }
-
- private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
- {
- if (_projectileController?.CanAcceptPositionPayload(
- update.Guid,
- update.Position,
- update.Velocity) == false)
- return;
- bool known = _liveEntities!.TryApplyPosition(
- update,
- isLocalPlayer: update.Guid == _playerServerGuid,
- forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null
- ? _playerController.BodyOrientation
- : null,
- currentLocalVelocity: update.Guid == _playerServerGuid && _playerController is not null
- ? _playerController.BodyVelocity
- : null,
- out var timestampDisposition,
- out var acceptedSpawn,
- out var timestamps);
- if (!known)
- {
- // Preserve the local streaming observer hint, but do not consume
- // a per-object timestamp until a live object can receive the
- // packet. Retail queues the full blob in this case (AD-32).
- if (update.Guid == _playerServerGuid)
- _lastLivePlayerLandblockId = update.Position.LandblockId;
- return;
- }
-
- PublishLocalPhysicsTimestamps(update.Guid, timestamps);
-
- if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Rejected)
- return;
- if (!_liveEntities.TryGetRecord(
- update.Guid,
- out LiveEntityRecord acceptedPositionRecord))
- {
- return;
- }
- ulong acceptedPositionAuthorityVersion =
- acceptedPositionRecord.PositionAuthorityVersion;
- ulong acceptedPositionVelocityAuthorityVersion =
- acceptedPositionRecord.VelocityAuthorityVersion;
- bool IsCurrentPositionOwner(
- AcDream.Core.World.WorldEntity? expectedEntity = null) =>
- _liveEntities.IsCurrentPositionAuthority(
- acceptedPositionRecord,
- acceptedPositionAuthorityVersion)
- && (expectedEntity is null
- || ReferenceEquals(
- acceptedPositionRecord.WorldEntity,
- expectedEntity));
- if (!IsCurrentPositionOwner())
- return;
-
- // A PlayerDescription/CreateObject may establish the live record
- // without either Position or enough render data. Bind streaming
- // readiness directly to this first accepted canonical Position before
- // translating it through the current world origin; projection recovery
- // below is a separate concern and may never be needed for UI-only state.
- if (_liveEntityHydration?.EnsureWorldOrigin(
- acceptedPositionRecord,
- acceptedPositionAuthorityVersion,
- acceptedSpawn) != true
- || !IsCurrentPositionOwner())
- return;
-
- var p = update.Position;
- int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
- int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
- var origin = new System.Numerics.Vector3(
- (lbX - _liveCenterX) * 192f,
- (lbY - _liveCenterY) * 192f,
- 0f);
- var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
-
- bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
- && update.Guid == _playerServerGuid
- && _playerController is not null;
- if (forceLocal)
- {
- if (!IsCurrentPositionOwner())
- return;
- var cellLocal = new System.Numerics.Vector3(
- p.PositionX, p.PositionY, p.PositionZ);
- _playerController!.BlipPosition(worldPos, p.LandblockId, cellLocal);
- SendImmediateLocalPositionEvent();
- if (!IsCurrentPositionOwner())
- return;
- }
-
- if (!_entitiesByServerGuid.ContainsKey(update.Guid))
- {
- if (!IsCurrentPositionOwner())
- return;
- AcDream.App.Rendering.ChildUnparentDisposition unparented =
- _equippedChildRenderer?.OnChildBecameUnparented(
- update.Guid,
- () =>
- {
- if (!IsCurrentPositionOwner())
- return;
- _liveEntityHydration!.RecoverProjection(
- acceptedPositionRecord,
- acceptedPositionAuthorityVersion,
- acceptedSpawn);
- })
- ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
- if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded
- or AcDream.App.Rendering.ChildUnparentDisposition.Pending)
- return;
- if (!IsCurrentPositionOwner())
- return;
- if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached)
- {
- _liveEntityHydration!.RecoverProjection(
- acceptedPositionRecord,
- acceptedPositionAuthorityVersion,
- acceptedSpawn);
- if (!IsCurrentPositionOwner())
- return;
- }
- }
-
- if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
- if (!IsCurrentPositionOwner(entity))
- return;
- _entityEffects?.MarkLiveOwnerPoseDirty(update.Guid);
- if (!IsCurrentPositionOwner(entity))
- return;
-
- // Phase A.1 / #135: track the PLAYER's last server-known landblock so the
- // streaming controller can follow the player in the fly-camera / pre-player-mode
- // (login hold) views. Filtered to our OWN character guid — resolving the original
- // Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
- // must NOT move the streaming observer: during a dungeon-login hold (player not
- // yet placed, so _playerController is null and the PortalSpace observer branch
- // can't apply) that would drift the observer off the pre-collapsed dungeon
- // landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
- // the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
- // (~line 1984) before world entry, so it is valid by the time updates arrive.
- if (update.Guid == _playerServerGuid)
- _lastLivePlayerLandblockId = update.Position.LandblockId;
-
- // B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for
- // the local player. Combined with [autowalk-mt] this answers
- // whether ACE's broadcast frequency during a server-initiated
- // auto-walk is dense enough to drive smooth visible motion (the
- // Option C viability check from the design spec). Gated on
- // ACDREAM_PROBE_AUTOWALK=1; skips remote entities.
- if (update.Guid == _playerServerGuid
- && AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- {
- string velStr = update.Velocity is { } v
- ? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})"
- : "vel=null";
- Console.WriteLine(System.FormattableString.Invariant(
- $"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}"));
- }
- var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
- ? entity.Rotation
- : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
- DumpMovementTruthServerEcho(update, worldPos);
-
- bool remoteHardTeleport = update.Guid != _playerServerGuid
- && timestamps.TeleportHookRequired;
- bool remotePlacementRequired = update.Guid != _playerServerGuid
- && (remoteHardTeleport
- || _remoteTeleportController?.HasPending(update.Guid) == true);
- if (remoteHardTeleport)
- {
- if (!RunRemoteTeleportHook(
- update.Guid,
- entity.Id,
- () => IsCurrentPositionOwner(entity)))
- {
- return;
- }
- }
-
- // Missiles reconcile the same predicted PhysicsBody in place. The
- // timestamp gate above already rejected stale corrections; returning
- // here prevents the generic remote locomotion path from allocating a
- // second body or interpolation owner for the projectile.
- if (_projectileController?.ApplyAuthoritativePosition(
- acceptedPositionRecord,
- acceptedPositionAuthorityVersion,
- acceptedPositionVelocityAuthorityVersion,
- worldPos,
- new System.Numerics.Vector3(
- p.PositionX,
- p.PositionY,
- p.PositionZ),
- rot,
- acceptedSpawn.Physics?.Velocity
- ?? System.Numerics.Vector3.Zero,
- p.LandblockId,
- _physicsScriptGameTime,
- _liveCenterX,
- _liveCenterY) == true)
- return;
-
- if (!_liveEntities.TryGetRecord(
- update.Guid,
- out LiveEntityRecord positionRecord)
- || !ReferenceEquals(positionRecord, acceptedPositionRecord)
- || !ReferenceEquals(positionRecord.WorldEntity, entity)
- || !_liveEntities.IsCurrentPositionAuthority(
- positionRecord,
- acceptedPositionAuthorityVersion))
- {
- 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;
- if (!_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId)
- || !_liveEntities.TryGetRecord(
- update.Guid,
- out LiveEntityRecord afterRebucket)
- || !ReferenceEquals(afterRebucket, positionRecord)
- || !ReferenceEquals(afterRebucket.WorldEntity, entity)
- || !_liveEntities.IsCurrentPositionAuthority(
- afterRebucket,
- acceptedPositionAuthorityVersion))
- {
- // A projection callback superseded or deleted this incarnation.
- // Never let an older UpdatePosition seed the replacement's
- // placement, interpolation, or collision state.
- return;
- }
-
- if (remotePlacementRequired)
- {
- _remoteTeleportController!.BeginPlacement(
- update.Guid,
- acceptedSpawn.InstanceSequence);
- if (!IsCurrentPositionOwner(entity))
- return;
- }
-
- // Commit B 2026-04-29 — keep the shadow registry in sync with
- // server-authoritative position so the player's collision broadphase
- // tests against the up-to-date target body. Skip the local player
- // (its body is the simulator, not a target). Retail does the
- // equivalent via SetPosition → change_cell → AddShadowObject
- // (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
- // #184 Slice 2b: the former players-only RAW-pos shadow sync is RETIRED.
- // It was a Slice-1 stopgap while grounded player remotes (old Path A) skipped
- // the sweep and tracked the server position closely. Now that Slice 2b runs
- // the SAME per-tick sweep + shadow-follows-resolved for players, writing the
- // raw (overlapping) server pos here would re-snap a packed player's shadow
- // into overlap once per UP and fight the in-tick de-overlap (research
- // finding 9). Player shadows now follow the RESOLVED body — via the DR-tick
- // loop (SyncRemoteShadowToBody, pose/cell-gated) and the player UP-branch tail
- // below (first-UP / no-Sequencer case), exactly like NPCs. Local-player
- // broadphase still tests an up-to-date remote shadow; it is just the resolved
- // body now, not the raw wire pos.
-
-// Track remote-entity motion for stop detection. Only record the
- // timestamp when position moved MEANINGFULLY (> 0.05m). Updates
- // that report the same position keep the old Time, so the
- // TickAnimations check can see when motion last changed.
- //
- // Also populate the dead-reckon state so TickAnimations can
- // integrate velocity between server updates and avoid teleport jitter.
- // Observed-velocity is computed from the position delta across
- // consecutive updates — this is the fallback when the motion table's
- // MotionData.Velocity is zero (NPCs without HasVelocity).
- if (update.Guid != _playerServerGuid)
- {
- var now = System.DateTime.UtcNow;
- if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev))
- {
- float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
- if (moveDist > 0.05f)
- _remoteMovementObservations[update.Guid] = (worldPos, now);
- // else: leave old entry so "Time" = last real movement time
- }
- else
- {
- _remoteMovementObservations[update.Guid] = (worldPos, now);
- }
-
- // Retail-faithful hard-snap on UpdatePosition.
- // Decompile: FUN_00559030 @ chunk_00550000.c:8232 writes
- // pos/rot directly into PhysicsObj+0x80..0xBC with no blending.
- // Between UpdatePositions, per-tick velocity integration keeps
- // the rendered position close to server truth so each snap is
- // small. When HasVelocity is set, we also seed PhysicsBody
- // velocity (matches retail's set_velocity call in the same
- // dispatcher).
- if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState))
- {
- rmState = CreateRemoteMotion(update.Guid);
- _remoteDeadReckon[update.Guid] = rmState;
- // Hard-snap orientation on first spawn so the per-tick
- // slerp doesn't visibly rotate from Identity to truth.
- rmState.Body.Orientation = rot;
- // #184 Slice 2b: PLACE the body at the server position on creation,
- // mirroring the UM handler's seed (:5176 `Body.Position =
- // entity.Position`). A UP-first RemoteMotion (created here before any
- // UM) was left at the default (0,0,0). Path A never swept, so that
- // stale origin was harmless — it caught up gradually. Now that Slice
- // 2b runs the sweep for grounded PLAYERS too, an unplaced body would
- // sweep from (0,0,0) in the server cell that does not contain it →
- // garbage resolved pos → the digest's INVISIBLE/misplaced-body bug.
- // Seeding here is the root-cause fix (the UP creation path should
- // seed exactly like the UM path); worldPos == entity.Position (the
- // unconditional snap at the top of this handler already ran).
- rmState.Body.Position = worldPos;
- }
-
- // PositionPack::UnPack initializes an absent velocity to zero;
- // MoveOrTeleport installs that exact vector with set_velocity.
- // The canonical seam wakes the retained ObjectClock and body in
- // one operation. Position-delta velocity below remains animation
- // diagnostics and is never substituted into physics.
- if (!_liveEntities.IsCurrentPositionAuthority(
- positionRecord,
- acceptedPositionAuthorityVersion))
- {
- return;
- }
- if (_liveEntities.IsCurrentVelocityAuthority(
- positionRecord,
- acceptedPositionVelocityAuthorityVersion)
- && !_liveEntities.TryCommitAuthoritativeVelocity(
- positionRecord,
- rmState.Body,
- acceptedSpawn.Physics?.Velocity
- ?? System.Numerics.Vector3.Zero,
- _physicsScriptGameTime))
- {
- return;
- }
-
- // Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a
- // fresh TELEPORT_TS, or the first placement of a cell-less body,
- // runs teleport_hook and SetPosition(0x1012) BEFORE the contact
- // test. In particular, an airborne UP cannot veto or undo this
- // authoritative destination. Do not pre-clear velocity or invent
- // grounded flags here: retail SetPosition derives contact from its
- // transition, while MoveOrTeleport does not consume arg5/arg6.
- if (remotePlacementRequired)
- {
- double teleportTime =
- (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
- bool projectionVisible = _liveEntities.TryGetRecord(
- update.Guid,
- out LiveEntityRecord teleportRecord)
- && teleportRecord.IsSpatiallyVisible;
- var placement = _remoteTeleportController!.TryApply(
- positionRecord,
- acceptedPositionAuthorityVersion,
- acceptedPositionVelocityAuthorityVersion,
- rmState,
- entity,
- worldPos,
- p.LandblockId,
- new System.Numerics.Vector3(
- p.PositionX,
- p.PositionY,
- p.PositionZ),
- rot,
- teleportTime,
- projectionVisible,
- acceptedSpawn.InstanceSequence,
- acceptedSpawn.PositionSequence);
- if (placement.Superseded
- || !IsCurrentPositionOwner(entity))
- {
- return;
- }
- if (!placement.Applied)
- {
- entity.SetPosition(rmState.Body.Position);
- entity.ParentCellId = rmState.CellId;
- entity.Rotation = rmState.Body.Orientation;
- if (rmState.CellId != 0)
- _liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId);
- return;
- }
-
- if (!IsCurrentPositionOwner(entity))
- return;
- entity.SetPosition(rmState.Body.Position);
- entity.Rotation = rmState.Body.Orientation;
- return;
- }
-
- // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
- // player remotes. Mirrors CPhysicsObj::MoveOrTeleport
- // (acclient @ 0x00516330) — airborne no-op, far-snap, near
- // InterpolateTo. Gated on IsPlayerGuid so NPCs continue through
- // the legacy synth-velocity branch below; their motion comes
- // from ServerVelocity / ServerMoveTo which the legacy path
- // already handles correctly.
- //
- if (IsPlayerGuid(update.Guid))
- {
- // InterpolationManager retains the complete target Position.
- // A near correction replaces both origin and orientation via
- // Position::subtract2; only placement/far branches snap here.
- // Adopt server's cell ID on every UP (airborne or grounded).
- // Required by the legacy airborne path's per-tick
- // 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.
- rmState.CellId = p.LandblockId;
-
- // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
- // server-pos snapshot forward AND print the per-UP comparison
- // between the max literal CSequence root-motion speed observed
- // since the last UP and the actual server broadcast pace. Both are sampled
- // over the same window so the ratio reflects real overshoot.
- {
- double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
- if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
- && rmState.LastServerPosTime > 0.0)
- {
- double dtServer = nowSecDiag - rmState.LastServerPosTime;
- if (dtServer > 0.001)
- {
- var serverDelta = worldPos - rmState.LastServerPos;
- float serverSpeed = (float)(serverDelta.Length() / dtServer);
- float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP;
- if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f)
- {
- System.Console.WriteLine(
- $"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s "
- + $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
- + $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}");
- }
- }
- }
- rmState.MaxRootMotionSpeedSinceLastUP = 0f;
- rmState.PrevServerPos = rmState.LastServerPos;
- rmState.PrevServerPosTime = rmState.LastServerPosTime;
- rmState.LastServerPos = worldPos;
- 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.
- 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
- // teleport-to-server-then-yank-back rubber-band.
- entity.SetPosition(rmState.Body.Position);
- return;
- }
-
- // ── LANDING TRANSITION ────────────────────────────────────────
- // First IsGrounded=true UP after rmState.Airborne signals landed.
- // Clear airborne flags, hard-snap to authoritative landing position,
- // clear interpolation queue (any pre-jump waypoints are stale).
- if (rmState.Airborne)
- {
- rmState.Airborne = false;
- rmState.Body.Velocity = System.Numerics.Vector3.Zero;
- rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
- | AcDream.Core.Physics.TransientStateFlags.OnWalkable;
- rmState.Interp.Clear();
- rmState.Body.Position = worldPos;
- rmState.Body.Orientation = rot;
-
- // #161: retail landing = MovementManager::HitGround
- // (minterp → moveto, 0x00524300 — the R5-V5 facade
- // relay) with the Gravity state bit STILL SET
- // (CMotionInterp::HitGround gates on state&0x400). The
- // re-apply dispatches the PRESERVED pre-fall forward
- // command → landing link → cycle. This replaces the
- // forced SetCycle, which read the then-clobbered
- // ForwardCommand (Falling) and re-set the pose it meant
- // to clear. See the twin block in TickAnimations
- // (VU.land).
- if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
- && aeForLand.Sequencer is not null)
- {
- EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid);
- }
- ulong landingStateAuthorityVersion =
- positionRecord.StateAuthorityVersion;
- rmState.Movement.HitGround();
- if (!IsCurrentPositionOwner(entity)
- || !ReferenceEquals(
- positionRecord.RemoteMotionRuntime,
- rmState))
- {
- return;
- }
- // DR bookkeeping only (partner of the jump-start
- // `State |= Gravity`).
- if (_liveEntities.IsCurrentStateAuthority(
- positionRecord,
- landingStateAuthorityVersion))
- {
- rmState.Body.State &=
- ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
- }
- return;
- }
-
- // ── 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)
- {
- // 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(
- 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
- // DELETED — retail never adapts a remote's animation from
- // observed pace (deviation map DEV-2; premise refuted at
- // decomp + ACE source + the S0 live capture, which shows
- // explicit 0x0005↔0x0007 UMs on every Shift toggle).
- // Player-remote cycles are UM-driven only.
- if (rmState.PrevServerPosTime > 0.0)
- {
- double nowSecVel = rmState.LastServerPosTime;
- double dtPos = nowSecVel - rmState.PrevServerPosTime;
- if (dtPos > 0.001)
- {
- var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos;
- rmState.ServerVelocity = synthVel;
- rmState.HasServerVelocity = true;
- }
- }
-
- // Sync the visible entity to the body — overrides the unconditional
- // entity.SetPosition(worldPos) snap at the top of this function.
- // For the far-snap branch this is a no-op (body == worldPos); for
- // the near-enqueue branch this prevents a 1-frame teleport-then-
- // yank-back rubber-band as TickAnimations chases worldPos via the
- // queue.
- //
- // #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed
- // body (mirrors the NPC UP-branch tail). Now that grounded players run
- // the DR-tick sweep + shadow-follows-resolved, the retired raw-pos sync
- // (top of this handler) would have re-snapped the shadow into overlap
- // each UP; this keeps collision == render for the first UP (before any
- // DR tick) and for no-Sequencer players. rmState.CellId is the server
- // cell adopted above (:5735). The per-tick loop keeps it current
- // between UPs (pose-gated). Commit the complete root before the
- // publication gate, matching SetPositionInternal ordering.
- entity.SetPosition(rmState.Body.Position);
- entity.ParentCellId = rmState.CellId;
- entity.Rotation = rmState.Body.Orientation;
- AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
- _liveEntities,
- positionRecord,
- entity,
- rmState,
- acceptedPositionAuthorityVersion,
- () => _remotePhysicsUpdater.SyncRemoteShadowToBody(
- entity.Id,
- rmState,
- _liveCenterX,
- _liveCenterY));
- return;
- }
-
- double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
- System.Numerics.Vector3? serverVelocity = update.Velocity;
- if (serverVelocity is null
- && !IsPlayerGuid(update.Guid)
- && rmState.LastServerPosTime > 0.0)
- {
- double elapsed = nowSec - rmState.LastServerPosTime;
- if (elapsed > 0.001)
- serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed;
- }
- if (serverVelocity is { } authoritativeVelocity)
- {
- rmState.ServerVelocity = authoritativeVelocity;
- rmState.HasServerVelocity = true;
- }
- else if (!IsPlayerGuid(update.Guid))
- {
- rmState.ServerVelocity = System.Numerics.Vector3.Zero;
- rmState.HasServerVelocity = false;
- }
- // R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping",
- // stale facing, pushed-into-player): while an entity is STUCK,
- // the sticky steer owns its frame — retail's UP corrections flow
- // through the InterpolationManager into the SAME adjust_offset
- // chain where StickyManager OVERWRITES them while armed
- // (PositionManager::adjust_offset 0x00555190 order; sticky
- // assigns m_fOrigin 0x00555430), so a server correction can
- // never fight an armed stick frame-by-frame. This legacy NPC
- // path hard-snaps OUTSIDE that chain, producing a visible
- // snap-out/steer-back oscillation at UP cadence (position) and
- // a stale-facing stomp (orientation). Faithful translation to
- // the snap architecture: suppress the position/orientation/
- // velocity snaps while stuck. LastServerPos/Time bookkeeping
- // still records below — server truth reasserts on the first UP
- // after unstick (bounded by the 1 s sticky lease). Register
- // row with TS-41/TS-44.
- bool snapSuppressedByStick =
- (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
- if (snapSuppressedByStick
- && AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled)
- {
- float snapDist = System.Numerics.Vector3.Distance(
- worldPos, rmState.Body.Position);
- Console.WriteLine(FormattableString.Invariant(
- $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
- }
- 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.
- //
- // 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
- {
- const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap
- const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap
- var localPlayerPosNpc = _playerController?.Position
- ?? System.Numerics.Vector3.Zero;
- 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);
-
- if (firstUpNpc || !willBeDrTickedNpc
- || distNpc > MaxPhysicsDistanceNpc
- || bodyToTargetNpc > BodySnapThresholdNpc)
- {
- // Placement / far / large-correction: SNAP + clear queue.
- rmState.Interp.Clear();
- rmState.Body.Position = worldPos;
- rmState.Body.Orientation = rot;
- }
- else
- {
- // Near DR correction: enqueue the waypoint for the per-tick
- // catch-up (Path B consumes it via ComputeOffset).
- 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;
- }
- }
- }
- // 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
- // the FIRST UP after the jump, which:
- // * restored Contact + OnWalkable,
- // * removed the Gravity flag,
- // * caused the next per-tick to stomp Velocity via
- // apply_current_movement (reading InterpretedState =
- // Ready, so Velocity.Z went to 0),
- // …so the body got stuck at the server-broadcast apex Z,
- // visibly hovering. The fix: leave Airborne true; the
- // per-tick post-resolve logic detects an actual landing
- // (resolveResult.IsOnGround && Velocity.Z <= 0) and clears
- // it then. Mirrors how PlayerMovementController re-grounds
- // the local player at the bottom of its arc.
- //
- // The position-snap above is still authoritative — if ACE
- // says the body is at Z=68 mid-arc, we render Z=68. But we
- // continue integrating gravity from there, so the body
- // proceeds along the parabolic path between UPs.
- // Adopt the server's cell ID as the transition starting cell.
- // Retail authoritatively hard-snaps cell membership here too; our
- // per-tick ResolveWithTransition sweep then advances CheckCellId
- // as the sphere crosses cells and writes the new cell back into
- // rmState.CellId so the NEXT frame starts in the correct cell.
- rmState.CellId = p.LandblockId;
-
- // Near UpdatePosition orientation is carried by the same complete
- // interpolation Frame as translation. Placement, airborne, and
- // far-correction branches above install the authoritative Frame
- // directly. Sticky still receives the shared Frame afterward and
- // may replace it while armed.
- rmState.LastServerPos = worldPos;
- rmState.LastServerPosTime = nowSec;
-
- if (rmState.HasServerVelocity
- && !snapSuppressedByStick
- && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
- {
- // NPC/monster remotes: PlanFromVelocity cycle selection from
- // UP-derived velocity (ACE broadcasts NPC motion patterns the
- // UM stream alone doesn't cover). Player remotes return early
- // inside — their cycles are UM-driven only per retail (L.2g
- // S5; DEV-2 deleted). Unification of NPCs onto the
- // CMotionInterp funnel is S6.
- //
- // D2 (Commit A 2026-05-03): tag whether the velocity feeding
- // ApplyServerControlledVelocityCycle is wire-explicit or
- // synthesized from position deltas (the common case).
- if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
- {
- string velSrc = update.Velocity is null ? "synth" : "wire";
- System.Console.WriteLine(
- $"[UPCYCLE_SRC] guid={update.Guid:X8} src={velSrc}");
- }
- ApplyServerControlledVelocityCycle(
- update.Guid,
- aeForVelocity,
- rmState,
- rmState.ServerVelocity);
- }
-
- // #184: sync the NPC shadow to the resolved/placed body (NOT the raw
- // server pos — the raw-pos sync was RETIRED for players too in Slice 2b)
- // so collision == render and the de-overlap isn't snapped away each UP.
- // Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
- // the per-tick loop skips). The per-tick loop keeps it current between
- // UPs, pose-gated. rmState.CellId is the server cell adopted above.
- // The root frame is committed before collision publication, as in
- // retail SetPositionInternal.
- entity.SetPosition(rmState.Body.Position);
- entity.ParentCellId = rmState.CellId;
- entity.Rotation = rmState.Body.Orientation;
- AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote(
- _liveEntities,
- positionRecord,
- entity,
- rmState,
- acceptedPositionAuthorityVersion,
- () => _remotePhysicsUpdater.SyncRemoteShadowToBody(
- entity.Id,
- rmState,
- _liveCenterX,
- _liveCenterY));
- }
-
- // F751 is only a notification gate; the accepted Position may arrive
- // before or after it. Canonical physics above always consumes the
- // packet first. The presentation coordinator exposes exactly one
- // sequence-correlated destination without reordering that state.
- if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply
- && update.Guid == _playerServerGuid
- && _teleportTransit.OfferDestination(
- update.TeleportSequence,
- timestamps.TeleportAdvanced,
- update,
- out var teleportDestination))
- {
- AimTeleportDestination(teleportDestination);
- }
- }
-
- // Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
- // player in PortalSpace until the destination is resident (TeleportWorldReady), then
- // fires Place (materialize) and FireLoginComplete (regain control + ack the server).
- // Replaces the old TeleportArrivalController hold/place machine.
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
private readonly TeleportViewPlaneController _teleportViewPlane = new();
private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
@@ -7531,11 +5409,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// pre-collapsed onto the spawn landblock; a camera-derived observer far from
// it trips ExitDungeonExpand and unloads the dungeon before it can hydrate —
// the player is never placed and login hangs with no dungeon. Previously
- // _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC
+ // The last-player-landblock hint was once set by ANY entity, so a dungeon-local NPC
// kept this branch on the dungeon; once it was filtered to the player guid
// (line ~4507), a not-yet-arrived player UP dropped to the camera branch.
// The fly camera is the OFFLINE observer only.
- if (_lastLivePlayerLandblockId is { } lid)
+ if (_liveEntityNetworkUpdates?.LastLivePlayerLandblockId is { } lid)
{
observerCx = (int)((lid >> 24) & 0xFFu);
observerCy = (int)((lid >> 16) & 0xFFu);
@@ -9340,27 +7218,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
LastFrameProfile: _frameProfiler.LastReport);
}
- private bool RunRemoteTeleportHook(
- uint serverGuid,
- uint localEntityId,
- Func isCurrent)
- {
- _remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
- EntityPhysicsHost? host =
- _liveEntities?.TryGetPhysicsHost(serverGuid, out var registered) == true
- ? registered as EntityPhysicsHost
- : null;
- return AcDream.App.Physics.RemoteTeleportHook.Execute(
- new AcDream.App.Physics.RemoteTeleportHookActions(
- CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
- UnStick: () => host?.PositionManager.UnStick(),
- StopInterpolating: () => remote?.Interp.Clear(),
- UnConstrain: () => host?.PositionManager.UnConstrain(),
- NotifyTeleported: () => host?.NotifyTeleported(),
- ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)),
- isCurrent);
- }
-
///
/// Advances the live object's retail UseTime phase before inbound packet
/// dispatch. Final root/part poses and their animation hooks are published
@@ -11613,8 +9470,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw),
setHeading: (h, _) => pcMoveTo.Yaw =
AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h),
- getOwnRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
- getOwnHeight: () => GetSetupCylinder(_playerServerGuid, playerEntity).Height,
+ getOwnRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
+ getOwnHeight: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Height,
contact: () => pcMoveTo.BodyInContact,
isInterpolating: () => false,
getVelocity: () => pcMoveTo.BodyVelocity,
@@ -11669,12 +9526,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
playerRecord.WorldEntity?.Position ?? pcMoveTo.Position,
pcMoveTo.BodyOrientation),
getVelocity: () => pcMoveTo.BodyVelocity,
- getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
+ getRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
inContact: () => pcMoveTo.BodyInContact,
minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(),
curTime: () => pcMoveTo.SimTimeSeconds,
physicsTimerTime: () => pcMoveTo.SimTimeSeconds,
- getObjectA: ResolvePhysicsHost,
+ getObjectA: _liveEntityMotionBindings.ResolvePhysicsHost,
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
// MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
// relay); the host chains the PositionManager leg after it.
@@ -11855,7 +9712,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// legacy Resolve above supplies the already-ported AdjustPosition +
// floor snap; this call supplies the missing object-aware ring search.
var (placementRadius, placementHeight) =
- GetSetupCylinder(_playerServerGuid, playerEntity);
+ _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity);
if (placementRadius < 0.05f)
{
placementRadius = 0.48f;
diff --git a/src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs b/src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs
new file mode 100644
index 00000000..ce197103
--- /dev/null
+++ b/src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs
@@ -0,0 +1,48 @@
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.World;
+
+internal interface ILiveEntitySameGenerationUpdateSink
+{
+ void OnDescription(uint ownerGuid, PhysicsSpawnData description);
+ void OnAppearance(ObjDescEvent.Parsed appearance);
+ void OnParent(CreateParentUpdate parent);
+ void OnPosition(WorldSession.EntityPositionUpdate position);
+ void OnPickup(PickupEvent.Parsed pickup);
+ void OnMovement(WorldSession.EntityMotionUpdate movement);
+ void OnState(SetState.Parsed state);
+ void OnVector(VectorUpdate.Parsed vector);
+}
+
+///
+/// Pins the equal-generation CObjectMaint::CreateObject tail order from
+/// retail SmartBox::HandleCreateObject @ 0x00454C80. The leading
+/// PhysicsDesc effect-profile refresh is the isolated AP-119 adaptation; the
+/// retail tail then remains ObjDesc, one Position relation, Movement, State,
+/// and Vector. Each channel still owns an independent timestamp gate.
+///
+internal static class LiveEntitySameGenerationUpdateRouter
+{
+ public static void Apply(
+ SameGenerationCreateObjectEvents refresh,
+ ILiveEntitySameGenerationUpdateSink sink)
+ {
+ ArgumentNullException.ThrowIfNull(sink);
+ sink.OnDescription(refresh.Appearance.Guid, refresh.Description);
+ sink.OnAppearance(refresh.Appearance);
+
+ if (refresh.Parent is { } parent)
+ sink.OnParent(parent);
+ else if (refresh.Position is { } position)
+ sink.OnPosition(position);
+ else if (refresh.Pickup is { } pickup)
+ sink.OnPickup(pickup);
+
+ if (refresh.Movement is { } movement)
+ sink.OnMovement(movement);
+ sink.OnState(refresh.State);
+ sink.OnVector(refresh.Vector);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs
new file mode 100644
index 00000000..8f7ee008
--- /dev/null
+++ b/tests/AcDream.App.Tests/Input/LocalPlayerImmediatePositionTests.cs
@@ -0,0 +1,114 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Numerics;
+using AcDream.App.Input;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.Input;
+
+public sealed class LocalPlayerImmediatePositionTests
+{
+ [Fact]
+ public void ForcePositionAcknowledgement_SendsOneExactAutonomousPositionAndStampsIt()
+ {
+ using var session = new WorldSession(
+ new IPEndPoint(IPAddress.Loopback, 9000));
+ session.PublishAcceptedLocalPhysicsTimestamps(
+ instance: 11,
+ serverControlledMove: 12,
+ teleport: 13,
+ forcePosition: 14);
+ var sent = new List();
+ session.GameActionCapture = sent.Add;
+
+ PlayerMovementController player = GroundedPlayer();
+ Position preTurnCanonical = player.CellPosition;
+ Quaternion rotation = Quaternion.Normalize(
+ Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f));
+ player.SetBodyOrientation(rotation);
+ Assert.NotEqual(rotation, preTurnCanonical.Frame.Orientation);
+ Assert.True(player.TryGetOutboundPosition(out Position outboundPosition));
+ var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { });
+
+ outbound.SendImmediatePosition(session, player);
+
+ byte[] body = Assert.Single(sent);
+ Assert.Equal(56, body.Length);
+ Assert.Equal(AutonomousPosition.GameActionOpcode, U32(body, 0));
+ Assert.Equal(1u, U32(body, 4));
+ Assert.Equal(AutonomousPosition.AutonomousPositionAction, U32(body, 8));
+ Assert.Equal(outboundPosition.ObjCellId, U32(body, 12));
+ Assert.Equal(outboundPosition.Frame.Origin.X, F32(body, 16));
+ Assert.Equal(outboundPosition.Frame.Origin.Y, F32(body, 20));
+ Assert.Equal(outboundPosition.Frame.Origin.Z, F32(body, 24));
+ Assert.Equal(rotation.W, F32(body, 28), precision: 6);
+ Assert.Equal(rotation.X, F32(body, 32), precision: 6);
+ Assert.Equal(rotation.Y, F32(body, 36), precision: 6);
+ Assert.Equal(rotation.Z, F32(body, 40), precision: 6);
+ Assert.Equal((ushort)11, U16(body, 44));
+ Assert.Equal((ushort)12, U16(body, 46));
+ Assert.Equal((ushort)13, U16(body, 48));
+ Assert.Equal((ushort)14, U16(body, 50));
+ Assert.Equal((byte)1, body[52]);
+ Assert.True(player.TryGetOutboundPosition(out Position currentOutbound));
+ Assert.False(player.ShouldSendPositionEvent(
+ currentOutbound,
+ player.ContactPlane,
+ player.SimTimeSeconds));
+ }
+
+ [Fact]
+ public void ImmediateAcknowledgement_RequiresSessionPlayerAndGroundContact()
+ {
+ using var session = new WorldSession(
+ new IPEndPoint(IPAddress.Loopback, 9000));
+ var sent = new List();
+ session.GameActionCapture = sent.Add;
+ var outbound = new LocalPlayerOutboundController((_, _, _, _, _, _) => { });
+ PlayerMovementController grounded = GroundedPlayer();
+
+ outbound.SendImmediatePosition(null, grounded);
+ outbound.SendImmediatePosition(session, null);
+ outbound.SendImmediatePosition(
+ session,
+ new PlayerMovementController(new PhysicsEngine()));
+
+ Assert.Empty(sent);
+ }
+
+ private static PlayerMovementController GroundedPlayer()
+ {
+ var engine = new PhysicsEngine();
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)50);
+ var heightTable = new float[256];
+ for (int i = 0; i < heightTable.Length; i++)
+ heightTable[i] = i;
+ engine.AddLandblock(
+ 0xA9B4FFFFu,
+ new TerrainSurface(heights, heightTable),
+ Array.Empty(),
+ Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+ var player = new PlayerMovementController(engine);
+ player.SetPosition(
+ new Vector3(96f, 97f, 50f),
+ 0xA9B40001u,
+ new Vector3(96f, 97f, 50f));
+ Assert.True(player.CanSendPositionEvent);
+ return player;
+ }
+
+ private static uint U32(byte[] body, int offset) =>
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(offset, 4));
+
+ private static ushort U16(byte[] body, int offset) =>
+ BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(offset, 2));
+
+ private static float F32(byte[] body, int offset) =>
+ BitConverter.Int32BitsToSingle(
+ BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(offset, 4)));
+}
diff --git a/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs b/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs
new file mode 100644
index 00000000..b66385f2
--- /dev/null
+++ b/tests/AcDream.App.Tests/Physics/DeferredLiveEntityMotionRuntimeBindingsTests.cs
@@ -0,0 +1,85 @@
+using System.Numerics;
+using AcDream.App.Physics;
+using AcDream.App.Rendering;
+using AcDream.App.World;
+using AcDream.Core.Net;
+using AcDream.Core.Physics.Motion;
+using AcDream.Core.World;
+
+namespace AcDream.App.Tests.Physics;
+
+public sealed class DeferredLiveEntityMotionRuntimeBindingsTests
+{
+ [Fact]
+ public void EveryReverseEdgeFailsBeforeBind()
+ {
+ var bridge = new DeferredLiveEntityMotionRuntimeBindings();
+ var entity = Entity();
+
+ Assert.Throws(() =>
+ bridge.GetSetupCylinder(1, entity));
+ Assert.Throws(() =>
+ bridge.RouteServerMoveTo(null!, 0, default));
+ Assert.Throws(() =>
+ bridge.StickToObjectFromWire(null, 1));
+ Assert.Throws(() =>
+ bridge.ClearTargetForHiddenEntity(1));
+ Assert.Throws(() =>
+ bridge.ResolvePhysicsHost(1));
+ }
+
+ [Fact]
+ public void BindIsSingleAssignmentAndAllCallsReachExactOwner()
+ {
+ var bridge = new DeferredLiveEntityMotionRuntimeBindings();
+ var owner = new RecordingBindings();
+ bridge.Bind(owner);
+
+ Assert.Equal((2f, 3f), bridge.GetSetupCylinder(1, Entity()));
+ Assert.True(bridge.RouteServerMoveTo(null!, 4, default));
+ bridge.StickToObjectFromWire(null, 5);
+ bridge.ClearTargetForHiddenEntity(6);
+ Assert.Null(bridge.ResolvePhysicsHost(7));
+
+ Assert.Equal(
+ ["cylinder:1", "moveto:4", "stick:5", "hidden:6", "host:7"],
+ owner.Calls);
+ Assert.Throws(() =>
+ bridge.Bind(new RecordingBindings()));
+ }
+
+ private static WorldEntity Entity() => new()
+ {
+ Id = 1,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ private sealed class RecordingBindings : ILiveEntityMotionRuntimeBindings
+ {
+ public List Calls { get; } = [];
+ public (float Radius, float Height) GetSetupCylinder(uint guid, WorldEntity entity)
+ {
+ Calls.Add($"cylinder:{guid}");
+ return (2f, 3f);
+ }
+ public bool RouteServerMoveTo(
+ MovementManager movement,
+ uint cellId,
+ WorldSession.EntityMotionUpdate update)
+ {
+ Calls.Add($"moveto:{cellId}");
+ return true;
+ }
+ public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) =>
+ Calls.Add($"stick:{targetGuid}");
+ public void ClearTargetForHiddenEntity(uint guid) => Calls.Add($"hidden:{guid}");
+ public IPhysicsObjHost? ResolvePhysicsHost(uint guid)
+ {
+ Calls.Add($"host:{guid}");
+ return null;
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs
new file mode 100644
index 00000000..b6885109
--- /dev/null
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs
@@ -0,0 +1,408 @@
+using System.Numerics;
+using AcDream.App.Physics;
+using AcDream.App.Streaming;
+using AcDream.App.World;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.Physics;
+
+public sealed class LiveEntityInboundAuthorityGateTests
+{
+ private const uint Guid = 0x50000021u;
+
+ [Fact]
+ public void Motion_StrictFreshnessAndWrapAreOwnedBeforePresentation()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 7, movement: 0xFFFE));
+ var published = new List();
+ var gate = new LiveEntityInboundAuthorityGate(
+ runtime,
+ (_, timestamps) => published.Add(timestamps));
+
+ Assert.False(gate.TryAcceptMotion(
+ Motion(instance: 7, movement: 0xFFFE, server: 1),
+ retainPayload: true,
+ out _,
+ out bool equalAccepted));
+ Assert.False(equalAccepted);
+
+ Assert.True(gate.TryAcceptMotion(
+ Motion(instance: 7, movement: 1, server: 1),
+ retainPayload: true,
+ out AcceptedMotionNetworkUpdate wrapped,
+ out bool wrapAccepted));
+ Assert.True(wrapAccepted);
+ Assert.Equal((ulong)2, wrapped.Record.MovementAuthorityVersion);
+ Assert.Single(published);
+
+ Assert.False(gate.TryAcceptMotion(
+ Motion(instance: 7, movement: 1, server: 1),
+ retainPayload: true,
+ out _,
+ out bool duplicateAccepted));
+ Assert.False(duplicateAccepted);
+ Assert.Single(published);
+ }
+
+ [Fact]
+ public void AutonomousLocalMotion_ConsumesTimestampButDoesNotPublishPayload()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 1));
+ int publishCount = 0;
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++);
+
+ Assert.False(gate.TryAcceptMotion(
+ Motion(instance: 1, movement: 2, server: 1, autonomous: true),
+ retainPayload: false,
+ out _,
+ out bool timestampAccepted));
+
+ Assert.True(timestampAccepted);
+ Assert.Equal(1, publishCount);
+ Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
+ Assert.Equal((ulong)1, record.MovementAuthorityVersion);
+ }
+
+ [Fact]
+ public void Vector_InvalidPayloadDoesNotConsumeSequenceAndDuplicateIsRejected()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 2));
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
+ var update = new VectorUpdate.Parsed(
+ Guid,
+ new Vector3(1f, 2f, 3f),
+ Vector3.Zero,
+ InstanceSequence: 2,
+ VectorSequence: 2);
+
+ Assert.False(gate.TryAcceptVector(update, payloadIsValid: false, out _));
+ Assert.True(gate.TryAcceptVector(
+ update,
+ payloadIsValid: true,
+ out AcceptedVectorNetworkUpdate accepted));
+ Assert.Equal((ulong)2, accepted.VectorAuthorityVersion);
+ Assert.False(gate.TryAcceptVector(update, payloadIsValid: true, out _));
+ }
+
+ [Fact]
+ public void Vector_WrappedSequenceIsAccepted()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 2, vector: 0xFFFE));
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
+
+ Assert.True(gate.TryAcceptVector(
+ new VectorUpdate.Parsed(
+ Guid,
+ Vector3.One,
+ Vector3.Zero,
+ InstanceSequence: 2,
+ VectorSequence: 1),
+ payloadIsValid: true,
+ out _));
+ }
+
+ [Fact]
+ public void State_EqualIsRejectedAndWrappedSequenceIsAccepted()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 3, state: 0xFFFE));
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
+
+ Assert.False(gate.TryAcceptState(
+ new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 3, 0xFFFE),
+ out _));
+ Assert.True(gate.TryAcceptState(
+ new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 3, 1),
+ out AcceptedStateNetworkUpdate accepted));
+ Assert.Equal((ulong)2, accepted.StateAuthorityVersion);
+ Assert.False(gate.TryAcceptState(
+ new SetState.Parsed(Guid, 0, 3, 1),
+ out _));
+ }
+
+ [Fact]
+ public void Position_UnknownLocalHintAndResetDoNotConsumeFuturePacket()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ int publishCount = 0;
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++);
+ WorldSession.EntityPositionUpdate update = Position(
+ Guid,
+ instance: 4,
+ position: 2,
+ cell: 0xAABB0001u);
+
+ Assert.False(gate.TryAcceptPosition(
+ update,
+ Guid,
+ Quaternion.Identity,
+ Vector3.Zero,
+ payloadIsValid: true,
+ out _));
+ Assert.Equal(0xAABB0001u, gate.LastLivePlayerLandblockId);
+ Assert.Equal(0, publishCount);
+
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 4, position: 1));
+ Assert.True(gate.TryAcceptPosition(
+ update,
+ Guid,
+ Quaternion.Identity,
+ Vector3.Zero,
+ payloadIsValid: true,
+ out AcceptedPositionNetworkUpdate accepted));
+ Assert.Equal(PositionTimestampDisposition.Apply, accepted.TimestampDisposition);
+ Assert.Equal((ulong)2, accepted.PositionAuthorityVersion);
+ Assert.Equal(1, publishCount);
+
+ gate.ResetSessionState();
+ Assert.Null(gate.LastLivePlayerLandblockId);
+ }
+
+ [Fact]
+ public void Position_InvalidPayloadDoesNotConsumeSequence()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 1));
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
+ WorldSession.EntityPositionUpdate update = Position(Guid, 5, 2, 0x01010001u);
+
+ Assert.False(gate.TryAcceptPosition(
+ update, Guid, Quaternion.Identity, Vector3.Zero, false, out _));
+ Assert.True(gate.TryAcceptPosition(
+ update, Guid, Quaternion.Identity, Vector3.Zero, true, out _));
+ Assert.False(gate.TryAcceptPosition(
+ update, Guid, Quaternion.Identity, Vector3.Zero, true, out _));
+ }
+
+ [Fact]
+ public void Position_WrappedSequenceIsAccepted()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 0xFFFE));
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
+
+ Assert.True(gate.TryAcceptPosition(
+ Position(Guid, 5, 1, 0x01010002u),
+ Guid,
+ Quaternion.Identity,
+ Vector3.Zero,
+ payloadIsValid: true,
+ out _));
+ }
+
+ [Fact]
+ public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, vector: 1));
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
+ Assert.True(gate.TryAcceptVector(
+ new VectorUpdate.Parsed(Guid, Vector3.One, Vector3.Zero, 5, 2),
+ payloadIsValid: true,
+ out AcceptedVectorNetworkUpdate accepted));
+
+ Assert.True(runtime.TryApplyVector(
+ new VectorUpdate.Parsed(Guid, Vector3.UnitX, Vector3.Zero, 5, 3),
+ out _));
+ Assert.False(runtime.IsCurrentVectorAuthority(
+ accepted.Record,
+ accepted.VectorAuthorityVersion));
+ }
+
+ [Fact]
+ public void State_NewerSameIncarnationPacketInvalidatesCapturedAuthority()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, state: 1));
+ var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { });
+ Assert.True(gate.TryAcceptState(
+ new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 5, 2),
+ out AcceptedStateNetworkUpdate accepted));
+
+ Assert.True(runtime.TryApplyState(
+ new SetState.Parsed(Guid, 0, 5, 3),
+ out _));
+ Assert.False(runtime.IsCurrentStateAuthority(
+ accepted.Record,
+ accepted.StateAuthorityVersion));
+ }
+
+ [Fact]
+ public void Motion_PublisherReplacementInvalidatesCapturedIncarnation()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 6));
+ var gate = new LiveEntityInboundAuthorityGate(
+ runtime,
+ (_, _) => runtime.RegisterLiveEntity(Spawn(Guid, instance: 7)));
+
+ Assert.False(gate.TryAcceptMotion(
+ Motion(instance: 6, movement: 2, server: 1),
+ retainPayload: true,
+ out _,
+ out bool timestampAccepted));
+ Assert.True(timestampAccepted);
+ Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord replacement));
+ Assert.Equal((ushort)7, replacement.Generation);
+ }
+
+ [Fact]
+ public void Position_PublisherNewerChannelInvalidatesCapturedAuthority()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 8, position: 1));
+ var nested = Position(Guid, 8, 3, 0x01010002u);
+ LiveEntityInboundAuthorityGate? gate = null;
+ gate = new LiveEntityInboundAuthorityGate(
+ runtime,
+ (_, _) => runtime.TryApplyPosition(
+ nested,
+ isLocalPlayer: true,
+ forcePositionRotation: Quaternion.Identity,
+ currentLocalVelocity: Vector3.Zero,
+ out _,
+ out _,
+ out _));
+
+ Assert.False(gate.TryAcceptPosition(
+ Position(Guid, 8, 2, 0x01010001u),
+ Guid,
+ Quaternion.Identity,
+ Vector3.Zero,
+ payloadIsValid: true,
+ out _));
+ Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
+ Assert.Equal((ulong)3, record.PositionAuthorityVersion);
+ }
+
+ [Fact]
+ public void Position_PublisherNewerVectorPreservesIndependentPositionAuthority()
+ {
+ LiveEntityRuntime runtime = Runtime();
+ runtime.RegisterLiveEntity(Spawn(Guid, instance: 9, position: 1, vector: 1));
+ var gate = new LiveEntityInboundAuthorityGate(
+ runtime,
+ (_, _) => runtime.TryApplyVector(
+ new VectorUpdate.Parsed(
+ Guid,
+ Vector3.UnitX,
+ Vector3.Zero,
+ InstanceSequence: 9,
+ VectorSequence: 2),
+ out _));
+
+ Assert.True(gate.TryAcceptPosition(
+ Position(Guid, 9, 2, 0x01010003u),
+ Guid,
+ Quaternion.Identity,
+ Vector3.Zero,
+ payloadIsValid: true,
+ out AcceptedPositionNetworkUpdate accepted));
+ Assert.True(runtime.IsCurrentPositionAuthority(
+ accepted.Record,
+ accepted.PositionAuthorityVersion));
+ Assert.False(runtime.IsCurrentVelocityAuthority(
+ accepted.Record,
+ accepted.VelocityAuthorityVersion));
+ }
+
+ private static LiveEntityRuntime Runtime() => new(
+ new GpuWorldState(),
+ new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
+
+ private static WorldSession.EntityMotionUpdate Motion(
+ ushort instance,
+ ushort movement,
+ ushort server,
+ bool autonomous = false) => new(
+ Guid,
+ default,
+ instance,
+ movement,
+ server,
+ autonomous);
+
+ private static WorldSession.EntityPositionUpdate Position(
+ uint guid,
+ ushort instance,
+ ushort position,
+ uint cell) => new(
+ guid,
+ new CreateObject.ServerPosition(
+ cell, 10f, 11f, 12f, 1f, 0f, 0f, 0f),
+ Velocity: null,
+ PlacementId: null,
+ IsGrounded: true,
+ InstanceSequence: instance,
+ PositionSequence: position,
+ TeleportSequence: 0,
+ ForcePositionSequence: 0);
+
+ private static WorldSession.EntitySpawn Spawn(
+ uint guid,
+ ushort instance,
+ ushort position = 1,
+ ushort movement = 1,
+ ushort state = 1,
+ ushort vector = 1)
+ {
+ var serverPosition = new CreateObject.ServerPosition(
+ 0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
+ var timestamps = new PhysicsTimestamps(
+ position,
+ movement,
+ state,
+ vector,
+ Teleport: 0,
+ ServerControlledMove: 1,
+ ForcePosition: 0,
+ ObjDesc: 1,
+ Instance: instance);
+ var physics = new PhysicsSpawnData(
+ RawState: (uint)PhysicsStateFlags.ReportCollisions,
+ Position: serverPosition,
+ Movement: null,
+ AnimationFrame: null,
+ SetupTableId: 0x02000001u,
+ MotionTableId: 0x09000001u,
+ SoundTableId: null,
+ PhysicsScriptTableId: null,
+ Parent: null,
+ Children: null,
+ Scale: null,
+ Friction: null,
+ Elasticity: null,
+ Translucency: null,
+ Velocity: null,
+ Acceleration: null,
+ AngularVelocity: null,
+ DefaultScriptType: null,
+ DefaultScriptIntensity: null,
+ Timestamps: timestamps);
+ return new WorldSession.EntitySpawn(
+ guid,
+ serverPosition,
+ 0x02000001u,
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ null,
+ null,
+ "fixture",
+ null,
+ null,
+ 0x09000001u,
+ PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
+ InstanceSequence: instance,
+ MovementSequence: movement,
+ ServerControlSequence: 1,
+ PositionSequence: position,
+ Physics: physics);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs
new file mode 100644
index 00000000..1936f0e7
--- /dev/null
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs
@@ -0,0 +1,96 @@
+using AcDream.App.Physics;
+
+namespace AcDream.App.Tests.Physics;
+
+public sealed class LiveEntityNetworkBranchRoutingTests
+{
+ [Fact]
+ public void Vector_ProjectileStopsCanonicalAndOrdinaryRoutes()
+ {
+ var calls = new List();
+
+ LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
+ () => { calls.Add("projectile"); return true; },
+ () => { calls.Add("canonical"); return true; },
+ () => calls.Add("ordinary"));
+
+ Assert.Equal(LiveEntityVectorRoute.Projectile, route);
+ Assert.Equal(["projectile"], calls);
+ }
+
+ [Fact]
+ public void Vector_CanonicalBodyRunsOnlyAfterProjectileDeclines()
+ {
+ var calls = new List();
+
+ LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
+ () => { calls.Add("projectile"); return false; },
+ () => { calls.Add("canonical"); return true; },
+ () => calls.Add("ordinary"));
+
+ Assert.Equal(LiveEntityVectorRoute.CanonicalBody, route);
+ Assert.Equal(["projectile", "canonical"], calls);
+ }
+
+ [Fact]
+ public void Vector_OrdinaryRemoteIsTheLastFallback()
+ {
+ var calls = new List();
+
+ LiveEntityVectorRoute route = LiveEntityVectorRouter.Route(
+ () => { calls.Add("projectile"); return false; },
+ () => { calls.Add("canonical"); return false; },
+ () => calls.Add("ordinary"));
+
+ Assert.Equal(LiveEntityVectorRoute.OrdinaryRemote, route);
+ Assert.Equal(["projectile", "canonical", "ordinary"], calls);
+ }
+
+ [Fact]
+ public void ForcePosition_BlipsAndAcknowledgesExactlyOnce()
+ {
+ int currentChecks = 0;
+ int blips = 0;
+ int acknowledgements = 0;
+
+ bool completed = LocalForcePositionTransaction.Apply(
+ isForcePosition: true,
+ () => { currentChecks++; return true; },
+ () => blips++,
+ () => acknowledgements++);
+
+ Assert.True(completed);
+ Assert.Equal(2, currentChecks);
+ Assert.Equal(1, blips);
+ Assert.Equal(1, acknowledgements);
+ }
+
+ [Fact]
+ public void ForcePosition_AcknowledgementInvalidationStopsTheTail()
+ {
+ bool current = true;
+ int acknowledgements = 0;
+
+ bool completed = LocalForcePositionTransaction.Apply(
+ isForcePosition: true,
+ () => current,
+ () => { },
+ () => { acknowledgements++; current = false; });
+
+ Assert.False(completed);
+ Assert.Equal(1, acknowledgements);
+ }
+
+ [Fact]
+ public void OrdinaryPositionDoesNotBlipOrAcknowledge()
+ {
+ int calls = 0;
+
+ Assert.True(LocalForcePositionTransaction.Apply(
+ isForcePosition: false,
+ () => { calls++; return false; },
+ () => calls++,
+ () => calls++));
+ Assert.Equal(0, calls);
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
index 96503f07..ab53bcac 100644
--- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
+++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
@@ -30,6 +30,23 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData("OnLiveEntityPruned")]
[InlineData("TearDownLiveEntityRuntimeComponents")]
[InlineData("CreateLiveEntityRuntimeTeardownPlan")]
+ [InlineData("RouteSameGenerationCreateObject")]
+ [InlineData("OnLiveMotionUpdated")]
+ [InlineData("OnLivePositionUpdated")]
+ [InlineData("OnLiveVectorUpdated")]
+ [InlineData("OnLiveStateUpdated")]
+ [InlineData("EnsureRemoteMotionBindings")]
+ [InlineData("ResolvePhysicsHost")]
+ [InlineData("GetSetupCylinder")]
+ [InlineData("RouteServerMoveTo")]
+ [InlineData("StickToObjectFromWire")]
+ [InlineData("ClearTargetForHiddenEntity")]
+ [InlineData("ApplyServerControlledVelocityCycle")]
+ [InlineData("RunRemoteTeleportHook")]
+ [InlineData("SendImmediateLocalPositionEvent")]
+ [InlineData("DispatchRemoteInboundMotion")]
+ [InlineData("CreateRemoteMotion")]
+ [InlineData("WillAdvanceRemoteMotion")]
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
@@ -52,6 +69,10 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData(typeof(LiveEntityHydrationController))]
[InlineData(typeof(DatLiveEntityProjectionMaterializer))]
[InlineData(typeof(LiveEntityRuntimeTeardownController))]
+ [InlineData(typeof(LiveEntityNetworkUpdateController))]
+ [InlineData(typeof(LiveEntityMotionRuntimeController))]
+ [InlineData(typeof(LiveEntityInboundAuthorityGate))]
+ [InlineData(typeof(DeferredLiveEntityMotionRuntimeBindings))]
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
{
foreach (FieldInfo field in helperType.GetFields(
diff --git a/tests/AcDream.App.Tests/World/LiveEntitySameGenerationUpdateRouterTests.cs b/tests/AcDream.App.Tests/World/LiveEntitySameGenerationUpdateRouterTests.cs
new file mode 100644
index 00000000..d63245e6
--- /dev/null
+++ b/tests/AcDream.App.Tests/World/LiveEntitySameGenerationUpdateRouterTests.cs
@@ -0,0 +1,94 @@
+using AcDream.App.World;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.World;
+
+public sealed class LiveEntitySameGenerationUpdateRouterTests
+{
+ [Theory]
+ [InlineData(0, "parent")]
+ [InlineData(1, "position")]
+ [InlineData(2, "pickup")]
+ [InlineData(3, null)]
+ public void Apply_PreservesRetailTailOrderAndExclusiveSpatialBranch(
+ int spatialKind,
+ string? expectedSpatial)
+ {
+ var sink = new RecordingSink();
+ SameGenerationCreateObjectEvents refresh = new(
+ Description: default,
+ Appearance: default,
+ Parent: spatialKind == 0 ? default(CreateParentUpdate?) : null,
+ Position: spatialKind == 1
+ ? default(WorldSession.EntityPositionUpdate?)
+ : null,
+ Pickup: spatialKind == 2 ? default(PickupEvent.Parsed?) : null,
+ Movement: default(WorldSession.EntityMotionUpdate?),
+ State: default,
+ Vector: default);
+
+ // Nullable default(T?) has no value; explicitly wrap the selected
+ // zero-valued fixture so the branch itself remains observable.
+ refresh = refresh with
+ {
+ Parent = spatialKind == 0
+ ? (CreateParentUpdate?)new CreateParentUpdate()
+ : null,
+ Position = spatialKind == 1
+ ? (WorldSession.EntityPositionUpdate?)new WorldSession.EntityPositionUpdate()
+ : null,
+ Pickup = spatialKind == 2
+ ? (PickupEvent.Parsed?)new PickupEvent.Parsed()
+ : null,
+ Movement = new WorldSession.EntityMotionUpdate(),
+ };
+
+ LiveEntitySameGenerationUpdateRouter.Apply(refresh, sink);
+
+ var expected = new List { "description", "appearance" };
+ if (expectedSpatial is not null)
+ expected.Add(expectedSpatial);
+ expected.AddRange(["movement", "state", "vector"]);
+ Assert.Equal(expected, sink.Events);
+ }
+
+ [Fact]
+ public void ParentTakesPrecedenceWhenMalformedFixtureOffersAllSpatialForms()
+ {
+ var sink = new RecordingSink();
+ var refresh = new SameGenerationCreateObjectEvents(
+ default,
+ default,
+ new CreateParentUpdate(),
+ new WorldSession.EntityPositionUpdate(),
+ new PickupEvent.Parsed(),
+ null,
+ default,
+ default);
+
+ LiveEntitySameGenerationUpdateRouter.Apply(refresh, sink);
+
+ Assert.Equal(
+ ["description", "appearance", "parent", "state", "vector"],
+ sink.Events);
+ }
+
+ private sealed class RecordingSink : ILiveEntitySameGenerationUpdateSink
+ {
+ public List Events { get; } = [];
+ public void OnDescription(uint ownerGuid, PhysicsSpawnData description) =>
+ Events.Add("description");
+ public void OnAppearance(ObjDescEvent.Parsed appearance) =>
+ Events.Add("appearance");
+ public void OnParent(CreateParentUpdate parent) => Events.Add("parent");
+ public void OnPosition(WorldSession.EntityPositionUpdate position) =>
+ Events.Add("position");
+ public void OnPickup(PickupEvent.Parsed pickup) => Events.Add("pickup");
+ public void OnMovement(WorldSession.EntityMotionUpdate movement) =>
+ Events.Add("movement");
+ public void OnState(SetState.Parsed state) => Events.Add("state");
+ public void OnVector(VectorUpdate.Parsed vector) => Events.Add("vector");
+ }
+}