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"); + } +}