From f961d7002347535b2207d70741285cb06adeefbd Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 20 Jul 2026 09:10:31 +0200 Subject: [PATCH] feat(physics): port retail complete object frame pipeline Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex --- AGENTS.md | 8 +- CLAUDE.md | 8 +- docs/architecture/acdream-architecture.md | 67 + docs/architecture/code-structure.md | 65 + .../retail-divergence-register.md | 18 +- docs/plans/2026-04-11-roadmap.md | 49 +- docs/plans/2026-05-12-milestones.md | 11 +- .../2026-05-04-l3-port/04-interp-manager.md | 26 +- .../2026-07-02-r1-csequence/r1-gap-map.md | 8 +- ...07-19-r6-complete-root-frame-pseudocode.md | 408 +++++ .../Input/LocalPlayerOutboundController.cs | 32 +- .../Input/LocalPlayerProjectionController.cs | 38 +- .../Input/PlayerMovementController.cs | 829 +++++----- .../Input/RetailLocalPlayerFrameController.cs | 78 +- .../LiveEntityOrdinaryPhysicsUpdater.cs | 226 +++ .../Physics/LiveEntityShadowPublisher.cs | 56 + .../Physics/ProjectileController.cs | 633 +++++-- .../Physics/RemoteInboundMotionDispatcher.cs | 145 ++ .../Physics/RemotePhysicsUpdater.cs | 594 ++++--- .../Physics/RemoteTeleportController.cs | 164 +- src/AcDream.App/Physics/RemoteTeleportHook.cs | 18 +- .../Physics/RemoteTeleportPlacement.cs | 20 +- src/AcDream.App/Rendering/GameWindow.cs | 1464 ++++++++++------- .../Rendering/LiveEntityAnimationScheduler.cs | 509 ++++++ .../RetailStaticAnimatingObjectScheduler.cs | 432 +++++ .../Rendering/StaticLiveRootCommitter.cs | 90 + .../Rendering/Vfx/AnimationHookFrameQueue.cs | 54 +- .../Rendering/Vfx/EntityEffectPoseRegistry.cs | 29 +- .../Rendering/Vfx/EntityScriptActivator.cs | 158 +- .../World/LiveEntityPresentationController.cs | 183 ++- src/AcDream.App/World/LiveEntityRuntime.cs | 558 ++++++- .../World/LiveEntityRuntimeViews.cs | 28 +- .../World/RetailInboundEventDispatcher.cs | 123 ++ .../Physics/AnimationSequencer.cs | 80 +- .../Physics/InterpolationManager.cs | 177 +- src/AcDream.Core/Physics/Motion/FrameOps.cs | 56 +- .../Physics/Motion/MotionDeltaFrame.cs | 52 +- .../Physics/Motion/MotionTableDispatchSink.cs | 33 +- src/AcDream.Core/Physics/Motion/MoveToMath.cs | 25 +- .../Physics/Motion/MovementManager.cs | 20 +- src/AcDream.Core/Physics/MotionInterpreter.cs | 2 +- src/AcDream.Core/Physics/PhysicsObjUpdate.cs | 22 +- .../Physics/ProjectilePhysicsStepper.cs | 343 +++- .../Physics/RemoteMotionCombiner.cs | 73 +- .../Physics/RetailObjectActivityGate.cs | 101 ++ .../Physics/RetailObjectManagerTail.cs | 34 + .../Physics/RetailObjectQuantumClock.cs | 122 ++ .../LocalPlayerProjectionControllerTests.cs | 123 ++ .../Input/PlayerMouseLookMovementTests.cs | 7 +- .../RetailLocalPlayerFrameControllerTests.cs | 122 +- .../LiveEntityOrdinaryPhysicsUpdaterTests.cs | 246 +++ .../Physics/PlayerOutboundPositionTests.cs | 41 +- .../Physics/ProjectileControllerTests.cs | 369 ++++- .../RemoteInboundMotionDispatcherTests.cs | 171 ++ .../Physics/RemotePhysicsUpdaterTests.cs | 735 ++++++++- .../Physics/RemoteTeleportControllerTests.cs | 230 +++ .../LiveEntityAnimationSchedulerTests.cs | 835 ++++++++++ ...tailStaticAnimatingObjectSchedulerTests.cs | 542 ++++++ .../Vfx/EntityEffectPoseRegistryTests.cs | 148 ++ .../Vfx/EntityScriptActivatorTests.cs | 187 +++ .../World/LiveEntityLifecycleStressTests.cs | 1 + .../LiveEntityPresentationControllerTests.cs | 388 +++++ .../World/LiveEntityRuntimeTests.cs | 643 +++++++- .../RetailInboundEventDispatcherTests.cs | 112 ++ .../Input/PlayerMovementControllerTests.cs | 352 +++- .../Physics/AnimationSequencerTests.cs | 51 + .../HumanoidMotionTableRootMotionTests.cs | 30 + .../Physics/InterpolationManagerTests.cs | 159 ++ .../Physics/Motion/MotionDeltaFrameTests.cs | 163 ++ .../Motion/MotionTableDispatchSinkTests.cs | 19 +- .../Motion/MoveToMathPositionHeadingTests.cs | 9 +- .../Physics/Motion/MovementManagerTests.cs | 21 + .../Motion/RemoteChaseEndToEndHarnessTests.cs | 115 +- .../Physics/MotionVelocityPipelineTests.cs | 2 +- .../Physics/ProjectilePhysicsStepperTests.cs | 34 + .../Physics/RetailObjectActivityGateTests.cs | 150 ++ .../Physics/RetailObjectQuantumClockTests.cs | 110 ++ 77 files changed, 12513 insertions(+), 1871 deletions(-) create mode 100644 docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md create mode 100644 src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs create mode 100644 src/AcDream.App/Physics/LiveEntityShadowPublisher.cs create mode 100644 src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs create mode 100644 src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs create mode 100644 src/AcDream.App/Rendering/StaticLiveRootCommitter.cs create mode 100644 src/AcDream.App/World/RetailInboundEventDispatcher.cs create mode 100644 src/AcDream.Core/Physics/RetailObjectActivityGate.cs create mode 100644 src/AcDream.Core/Physics/RetailObjectQuantumClock.cs create mode 100644 tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/RemoteInboundMotionDispatcherTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs create mode 100644 tests/AcDream.App.Tests/World/RetailInboundEventDispatcherTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/Motion/MotionDeltaFrameTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/RetailObjectActivityGateTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/RetailObjectQuantumClockTests.cs diff --git a/AGENTS.md b/AGENTS.md index dc7e3334..eed2efa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,8 +111,12 @@ movement queries. **M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent, component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect -foundation. **Next:** connected single-client magic UI/casting visual gate, then the final two-client -portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6/TS-42, Track MP0. +foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete; +three independent conformance/architecture/adversarial re-reviews are clean, the Release build has +zero warnings, and the full suite is 6,446 passed / 5 skipped. Its remaining ownership cleanup and +registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 +locomotion/collision/projectile/teleport visual and performance gate, then the final two-client +portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6 residuals, Track MP0. Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below. For canonical state, read in this order: diff --git a/CLAUDE.md b/CLAUDE.md index 08fb1219..4f3e6be9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,8 +109,12 @@ movement queries. **M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent, component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect -foundation. **Next:** connected single-client magic UI/casting visual gate, then the final two-client -portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6/TS-42, Track MP0. +foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete; +three independent conformance/architecture/adversarial re-reviews are clean, the Release build has +zero warnings, and the full suite is 6,446 passed / 5 skipped. Its remaining ownership cleanup and +registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6 +locomotion/collision/projectile/teleport visual and performance gate, then the final two-client +portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6 residuals, Track MP0. Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below. For canonical state, read in this order: diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c132f59a..ef0610bc 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -207,16 +207,22 @@ src/ Physics/ ProjectileController.cs -> live-record projectile orchestration/corrections RemotePhysicsUpdater.cs -> ordinary/Hidden remote narrow-tick integration + LiveEntityOrdinaryPhysicsUpdater.cs -> manager-less body Transition commits + RemoteInboundMotionDispatcher.cs -> animation-optional retail UM funnel RemoteTeleportController.cs -> incarnation-scoped loaded/pending placement owner RemoteTeleportHook.cs -> ordered retail teleport teardown seam RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit World/ LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership + RetailInboundEventDispatcher.cs -> update-thread packet/frame FIFO barrier LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier Rendering/ GameWindow.cs -> still owns too much runtime wiring + LiveEntityAnimationScheduler.cs -> canonical ordinary live-object workset + RetailStaticAnimatingObjectScheduler.cs -> retail static-animation workset + StaticLiveRootCommitter.cs -> static root pose/collision commit boundary TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path Wb/WbDrawDispatcher.cs -> ordinary live/static entity draw dispatch Wb/EnvCellRenderer.cs -> indoor cell-shell draw path @@ -285,6 +291,67 @@ What exists and is active: owns the frame; delete, generation replacement, pickup/parent leave-world, and session reset use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map. +- `LiveEntityAnimationScheduler` is the one update-thread walk over spatial + live roots. It admits each incarnation's `RetailObjectQuantumClock`, advances + PartArray state, then selects exactly one movement owner: remote motion, the + retained projectile body, or `LiveEntityOrdinaryPhysicsUpdater` for a + manager-less canonical body. Every hook boundary revalidates record, + component, and object-clock epoch before transition, manager-tail, pose, or + rebucket commits. `RetailStaticAnimatingObjectScheduler` remains a separate + workset, matching `CPhysics::UseTime`: Setup DefaultAnimation is installed at + PartArray construction, while DAT and live PhysicsState-Static owners advance + whole elapsed intervals through `animate_static_object` semantics. A live + static owner shares the record's canonical PartArray and PhysicsBody; raw + static omega commits the root, effect pose, and collision shadow together, + while zero omega does not reflood collision and Hidden never restores it. + The typed animation view copies the runtime's concrete spatial dictionary + through reusable storage, so update and render classification allocate + nothing and scale with resident owners rather than retained KnownObjects. +- `RemoteInboundMotionDispatcher` is the single remote + `MovementManager::unpack_movement` owner. Animated and animation-less live + objects differ only by the presence of a PartArray dispatch sink; packet + head interrupt/style routing, MoveTo cases, case-0 wholesale state, sticky, + and long-jump ordering cannot drift between two `GameWindow` branches. +- Ordinary remote/manager-less Transition commits publish the resolved body, + `WorldEntity` root, and full cell before invoking the canonical rebucket + writer. That writer is a re-entrant lifetime boundary: the caller immediately + revalidates the exact record/runtime and publishes no collision shadow or + manager tail if the destination became pending or the GUID was replaced. + `LiveEntityPresentationController` owns the symmetric non-projectile + collision-residency edge: loaded-to-pending suspends the retained shadow, + hydration restores it immediately, and `OnLiveEntityReady` reconciles an + object that materialized pending before its collision registration existed. + Direct local-player and authoritative remote publication use the same + ordering: commit root, rebucket, then prove exact-record spatial residency + before touching collision. Remote shadows are pose/cell-gated, including + complete sign-invariant orientation for offset/multipart Setup shapes and a + forced refresh on cell-only transitions. Projectile residency remains + exclusively in `ProjectileController`. +- `RetailInboundEventDispatcher` wraps every live-object session callback and + each complete live-object frame phase. Synchronous App observers can enqueue + another packet, but that packet cannot interleave halfway through the older + packet's collision, rebucket, shadow, pose, hook, or manager tail. The live + record retains independent Position, State, Vector, and Movement authority + versions plus a narrow shared velocity version for the three packet families + that can install velocity. This mirrors retail's single update-thread FIFO + without incorrectly making an accepted State cancel an accepted Position. + Its direct frame/packet path accepts state plus a cached static callback and + performs zero allocation; only real nested reentrancy materializes a queued + heterogeneous operation. +- Projectile prediction is a two-phase transaction around retail's + `process_hooks` slot. `BeginQuantum` holds a candidate while leaving the + canonical body at its begin frame; `CompleteQuantum` commits only if the + exact record/runtime and prediction version still match. Position, Vector, + and State corrections therefore win if accepted between the two halves. + Teleport placement uses the same exact-record and per-channel authority + checks, so a newer Position supersedes an older resolver while an independent + State is preserved alongside the completed placement. +- `EntityEffectPoseRegistry` assigns a monotonic lifetime to every local-ID + incarnation. `AnimationHookFrameQueue` snapshots it before semantic + `AnimationDone` callbacks and revalidates it before every semantic completion + and every routed hook. A delete/local-ID reuse during capture or during an + earlier hook can never advance the displaced sequencer or send the old + owner's remaining sound, particle, or light hooks to its replacement. - `RemoteTeleportController` owns the placement half of a fresh remote teleport after `RemoteTeleportHook` has torn down movement/target state. It collision-seats loaded destinations through `RemoteTeleportPlacement`; an diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 660994af..eb6e0b8e 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -165,6 +165,9 @@ src/AcDream.App/ ├── Rendering/ │ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator │ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI) +│ ├── LiveEntityAnimationScheduler.cs # shipped: ordinary live-object update workset +│ ├── RetailStaticAnimatingObjectScheduler.cs # shipped: separate static-animation workset +│ ├── StaticLiveRootCommitter.cs # live static root → pose + collision boundary │ ├── TerrainModernRenderer.cs # (already exists) │ ├── TextureCache.cs # (already exists) │ ├── ParticleRenderer.cs # (already exists) @@ -176,12 +179,16 @@ src/AcDream.App/ ├── Physics/ │ ├── ProjectileController.cs # canonical live-record projectile orchestration │ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration +│ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path +│ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate +│ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel │ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership │ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions │ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit ├── World/ │ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots │ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation +│ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier │ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects │ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain │ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations @@ -239,6 +246,64 @@ allocators fail fast instead of wrapping into another landblock's namespace. All other non-Parent packet families still need the future general queue tracked by divergence AD-32. +The per-frame object body is no longer an animation-dictionary loop inside +`GameWindow`. `LiveEntityAnimationScheduler` snapshots canonical spatial root +records and advances the incarnation-stable object clock, PartArray, hooks, +one selected movement owner, and manager tail in retail order. Manager-less +bodies delegate their candidate/Transition/cell/shadow commit to +`LiveEntityOrdinaryPhysicsUpdater`; retained projectile bodies and remote +MovementManagers remain mutually exclusive movement owners. Static animation +is deliberately separate: `RetailStaticAnimatingObjectScheduler` owns the +`CPhysics::static_animating_objects` workset for DAT and live PhysicsState- +Static owners with Setup DefaultAnimation. Both schedulers are wired by +`GameWindow`, but neither owns GUID identity or logical resources. +The typed animation view fills its reusable snapshot and render-ID set from +`LiveEntityRuntime`'s concrete spatial dictionary, avoiding interface-enumerator +boxing on both update and render hot paths. +`RemoteInboundMotionDispatcher` similarly keeps UpdateMotion protocol behavior +outside `GameWindow`: GameWindow resolves the canonical record/body and the +optional PartArray sink, while one dispatcher owns retail's interrupt, style, +MoveTo/type-0, sticky, and standing-long-jump order. Static root projection is +bounded by `StaticLiveRootCommitter`, which synchronizes changed roots to +effects and collision without rebuilding zero-omega shadows or resurrecting a +Hidden/withdrawn registration. + +Synchronous network and lifecycle callbacks are bounded by +`RetailInboundEventDispatcher`. It owns no wire state and no identity; it only +serializes nested live-object operations until the current packet or full +object-frame tail completes. State-bearing direct dispatch is allocation-free; +only a genuinely nested operation allocates its retained queue wrapper. +`LiveEntityRecord` then supplies exact-incarnation +and per-channel authority versions at callback boundaries. Position, State, +Vector, and Movement remain independent, while a separate velocity version +invalidates only an older operation that would overwrite a newer velocity +installed by Position, Vector, or Movement. This prevents re-entrant App +observers from creating call-stack ordering that retail's update-thread packet +FIFO cannot produce. + +Pose-dependent hook deferral is similarly incarnation-scoped rather than GUID- +or local-ID-scoped. `EntityEffectPoseRegistry` publishes a monotonic pose-owner +lifetime, and `AnimationHookFrameQueue` captures it before semantic callbacks +and rechecks it before each semantic AnimationDone and each routed hook. Static animation retains `process_hooks` +until its root, live parts, and children are published; withdrawal invalidates +both its prepared pose and pending hook tail. + +Resolved ordinary motion commits its body/root/contact state before the +canonical full-cell setter enters `LiveEntityRuntime.RebucketLiveEntity`. +Because that setter may synchronously move the projection to pending or replace +the GUID, both `RemotePhysicsUpdater` and `LiveEntityOrdinaryPhysicsUpdater` +revalidate the exact incarnation before collision or manager-tail publication. +Collision residency itself is projection-owned, not updater-owned: +`LiveEntityPresentationController` suspends retained non-projectile shadows on +every unavailable-projection edge, restores them on hydration, and reconciles +the pending-first case at `OnLiveEntityReady` after collision registration. +Local projection and both authoritative remote UpdatePosition tails commit the +complete root before rebucketing, then publish collision only through an +exact-record/spatial-residency gate. Remote reflood tracks translation, +sign-invariant complete orientation, and cell changes so an in-place turn or a +same-pose EnvCell crossing cannot leave offset Setup shapes stale. +The projectile controller retains its separate body/InWorld/shadow edge owner. + Remote teleport placement is bounded in `Physics/RemoteTeleportController`, not `GameWindow`: it retains at most one pending request per materialized incarnation, scopes it by the live generation and accepted PositionSequence, diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b26124da..f3722f77 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) +## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -105,7 +105,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 89 active rows +## 3. Documented approximation (AP) — 85 active rows Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -176,9 +176,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-73 | **Character raises mutate optimistically, contrary to retail's server-authoritative flow** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits XP/credits. Named retail permits one request in flight, ghosts the clicked button, and waits for an authoritative quality-change element message before changing displayed state (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE usually accepts client-affordable raises, so its later property echoes conceal the incorrect prediction; Wave 8 removes local mutation and owns one awaiting request | A rejected/reordered raise can display invented state until a later full refresh, and repeated clicks can create multiple speculative spends | `gmAttributeUI`/`gmSkillUI` raise and quality-change paths, pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | --- -| AP-75 | **NARROWED 2026-07-17 — adapter-boundary `adjust_motion` + turn-omega synthesis only.** The locomotion-velocity half is RETIRED: CSequence now retains literal `MotionData.Velocity × speed`, and remote translation consumes the complete Frame emitted by `CSequence::update` before interpolation. Remaining: `SetCycle` (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp`; GameWindow's local-player path can still pass raw ids), and (b) synthesizes ±π/2×speed omega for dat-silent TurnRight/TurnLeft after dispatch | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + turn-omega tail); retired velocity consumer in `RemotePhysicsUpdater`/`RemoteMotionCombiner` | (a) preserves unadjusted local callers until they all enter through MotionInterpreter; (b) preserves remote rotation until the literal CSequence orientation delta replaces the ObservedOmega side channel (AP-76) | A DAT with authored turn omega different from π/2 is overridden only when the table is silent; boundary remapping can become a double-adjust if a future caller already normalizes a raw left/back command but still passes its original id | `CMotionInterp::adjust_motion` @305343; `add_motion` 0x005224B0; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire with the remaining R6 rotation/caller unification | -| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) | -| AP-77 | **NARROWED 2026-07-17 — null-sink/headless `apply_current_movement` fallback only.** When `MotionInterpreter.DefaultSink` is absent, `ApplyCurrentMovementInterpreted` writes grounded body velocity directly via `get_state_velocity`/`set_local_velocity` instead of dispatching through the animation-table backend. Every production animated player and remote binds `MotionTableDispatchSink`; the local controller also consumes literal sequencer root displacement, so this tail is not the live humanoid locomotion owner | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Keeps MotionInterpreter usable in isolated physics tests and for an entity with no animation runtime; production animation owners take the retail sink path | A future production entity instantiated without a DefaultSink silently falls back to command-derived body velocity and can foot-slide or drift from its animation | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when animation-less production objects have an explicit motion owner | +| 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 gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail 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) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) | | 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) | @@ -186,9 +185,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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 (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); 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/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `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 movement-gated per-tick re-flood** (remote-creature de-overlap #184, 2026-07-07): a moving NPC remote's collision shadow is re-registered at its RESOLVED body position (`SyncRemoteShadowToBody` → `ShadowObjects.UpdatePosition`) so neighbours + the player de-overlap / collide against where the monster actually is (== where it renders), and the de-overlap PERSISTS. This is retail-faithful in EFFECT (retail re-registers a moved object's shadow every accepted transition step, `SetPositionInternal`→`remove/add_shadows_to_cells` 0x00515330), but the IMPLEMENTATION differs: acdream runs the FULL `RegisterMultiPart` cell-flood, gated on `|Body−LastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip — incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site — a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn — the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) — only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 → `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) | +| 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-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` used to rotate CSequence's literal root-motion delta when the interpolation queue is empty/head-reached is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) | | 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` | @@ -216,7 +214,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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` | -## 4. Temporary stopgap (TS) — 32 active rows (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 2026-07-07 — NPC UP unified onto the interp queue, gate retained for orientation) +## 4. Temporary stopgap (TS) — 34 active rows (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) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -249,12 +247,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 | | TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly | | ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 | -| TS-44 | NPC UpdatePosition reconciliation (position + orientation) is SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`). **#184 (2026-07-07) UNIFIED the NPC path onto the interp queue** — the retire-condition mechanism is now ported: the grounded UP routes through `CPhysicsObj::MoveOrTeleport` (Enqueue) and the per-tick catch-up SEEDS the `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES it while armed (0x00555190 / 0x00555430 assigns m_fOrigin), exactly like the player-remote branch — so POSITION is now handled retail-faithfully by the compose-overwrite whether the gate is on or off. The `snapSuppressedByStick` gate is RETAINED for two residuals: (a) it suppresses the during-stick **Enqueue** so a stale server waypoint never enters the queue, and (b) it gates the **orientation** hard-snap (still outside the interp chain — retail's sticky owns facing). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's position mechanism (sticky-overwrites-interp) is now REACHABLE for NPCs (#184 gave them the interp-queue architecture); the gate remains only for orientation + during-stick Enqueue cleanliness | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target) keeps a stale orientation until unstick+next UP; bounded by the 1 s lease | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330 (NPC UP routing, #184); retire the orientation residual in R6 | +| 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-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-84` (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 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/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `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 | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 6c4cf736..d6180334 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -872,9 +872,52 @@ diagnostic scaffolding, not yet the final collision system. 183-case/funnel/moveto/chase/sticky suites unmodified. Arc close-out: `docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`. Open follow-ons carried: #167 (ConstraintManager arming). TS-42's per-tick completion/ - manager order retired in the first R6 cutover on 2026-07-19; complete-frame - plumbing, PositionManager interpolation, quantum ownership, and side-channel - deletion remain R6 scope. + manager order retired in the first R6 cutover on 2026-07-19. The second R6 + cutover now carries complete CSequence Frames (translation + DAT omega), + one incarnation-stable live-object quantum clock across local, remote, + Hidden, and projectile components, complete interpolation orientation, and + no ObservedOmega/synthetic-turn side channel. It also ports the 96-unit + PartArray Active gate plus immediate-active `prepare_to_enter_world` rebasing + and preserves live identity across loaded/pending rebucketing. Semantic + AnimationDone is in retail's pre-transition manager slot; TS-50 explicitly + carries the remaining pose-dependent hook-router delivery delay. The + Setup/state-driven `static_animating_objects` workset is now separate as + retail requires for both DAT and live physics-Static owners, including + unconditional PartArray default installation, direct 30-fps + DefaultAnimation, short residency-gap catch-up, and the greater-than-two- + second discard. Live static entries reuse the canonical record PartArray and + body; `animate_static_object` applies its raw omega without elapsed scaling + and commits changed render/effect/collision roots together without zero-omega + refloods or Hidden-shadow resurrection. + No-animation live remotes remain in the canonical ordinary-object workset, + and one animation-optional inbound dispatcher now owns their same retail + UpdateMotion funnel and near-correction consumption. `PerformMovement` + reactivates its owner before validation. A shared update-thread inbound FIFO + now prevents synchronous App callbacks from interleaving packets or frame + tails, while exact-record Position/State/Vector/Movement authority versions + preserve retail's independent timestamp channels and a narrow velocity token + resolves their one overlapping write. Projectile Begin/Complete prediction, + teleport placement, landing callbacks, Hidden transitions, and deferred + animation hooks all revalidate those exact ownership boundaries. Pose-owner + lifetime versions are captured before AnimationDone and checked before each + semantic completion and routed hook, closing delete/local-ID reuse during + both capture and drain. Direct frame/packet FIFO dispatch and spatial + animation update/render traversal are allocation-free after warmup; only + real nested work allocates a retained queue node. TS-51 records + the remaining shared particle/PhysicsScript tail timing. The ordinary + live-object scheduler and generic body transition path now live outside + GameWindow; body-backed animations, retained post-Missile bodies, visibility- + edge clock/shadow residency (including initial pending materialization), + callback-safe cross-cell rebucketing, and teardown all share the canonical + live record. Local and authoritative remote collision publication now occurs + only after exact-owner spatial revalidation; remote shadows refresh for + translation, complete orientation, or cell change. Folding + InterpolationManager into the owned + PositionManager facade remains the final R6 ownership cleanup. + Final 2026-07-20 gate: three independent retail/architecture/adversarial + re-reviews are clean; Release build is warning-free; the repository suite is + 6,446 passed / 5 skipped. A connected locomotion/collision/projectile/ + teleport visual and performance rebaseline remains the next gate. - **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).** ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL remote entities (players, NPCs, monsters — one funnel, user-approved): diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 24890cd4..eea2d513 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -10,10 +10,15 @@ favorite spell bar, connected casting/enchantment effects, and portal-space presentation have passed their single-client visual gates. The corrective bounded render/resource-lifetime integration passed its seven-destination connected stress gate without a crash or cumulative collapse. The remaining M3 visual -requirement is the final two-client portal-out/materialize observer gate. +requirement is the final two-client portal-out/materialize observer gate. The +new R6 object-update cutover also requires one connected locomotion/collision/ +projectile/teleport visual and performance rebaseline before that observer gate. Carried: -#145-residual, #116 slide-response, the remaining R6 object-pipeline cutover -(TS-42 manager order retired 2026-07-19), and Track MP0. +#145-residual, #116 slide-response, the remaining R6 ownership cleanup plus +registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset +cutover is automated-test complete 2026-07-20; three independent re-reviews are +clean, Release builds with zero warnings, and 6,446 tests pass / 5 skip), and +Track MP0. --- diff --git a/docs/research/2026-05-04-l3-port/04-interp-manager.md b/docs/research/2026-05-04-l3-port/04-interp-manager.md index cbfacf3e..a3a8fdc3 100644 --- a/docs/research/2026-05-04-l3-port/04-interp-manager.md +++ b/docs/research/2026-05-04-l3-port/04-interp-manager.md @@ -4,6 +4,15 @@ **Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp). **Cross-check:** `src/AcDream.Core/Physics/InterpolationManager.cs` (current port), `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` (cdb-traced). +> **2026-07-19 correction:** the May audit correctly identified that +> `adjust_offset` mutates its `Frame*`, but incorrectly described the result as +> translation-only and the old `Vector3` API as equivalent. Retail preserves +> the complete relative quaternion produced by `Position::subtract2` unless +> `keep_heading` explicitly replaces that rotation with heading zero. R6 now +> carries the complete Frame. See +> `docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md`. Section 7 is +> retained as a historical snapshot of the May port, not current status. + --- ## 1. Class layout (verbatim from `acclient.h`) @@ -135,7 +144,9 @@ Our current port does duplicate-prune against only the last entry (`_queue.Last` ## 4. `InterpolationManager::adjust_offset` @ `0x00555d30` Signature: `void adjust_offset(InterpolationManager *this, Frame *arg2, double arg3)` -- `arg2` = output Frame (already-zeroed identity Frame from caller `CPhysicsObj::UpdatePartsInternal` @ `0x00512c3c`). +- `arg2` = the mutable per-object delta Frame. `CPartArray::Update` may already + have written root translation and orientation into it; active interpolation + replaces that complete Frame. - `arg3` = frame `dt` in seconds (double). ### Critical answer: **arg2 is MUTATED IN PLACE.** Not a delta-return. @@ -146,13 +157,18 @@ The last meaningful action (line 353253) is: 00555f10 Frame::operator=(arg2, &__return); ``` -where `__return` is a Frame whose `m_fOrigin` was just scaled to the per-frame step, and whose rotation is 0 (or kept-heading) (line 353251). The caller composes this into the world position with: +where `__return` is the complete relative Frame from `Position::subtract2`. +Only `m_fOrigin` is scaled to the per-frame step. Its quaternion remains the +target-relative rotation unless `keep_heading` calls `Frame::set_heading(0)`. +The caller composes this into the world position with: ```c 00512d22 Frame::combine(arg3 /*world out*/, &this->m_position.frame, &var_40 /*= arg2*/); ``` -**So `adjust_offset` writes a translation-only Frame — the caller treats it as an offset Frame to combine with the body's current position.** Our acdream port returns a `Vector3` delta, which the caller adds to the body — equivalent semantics. +**So `adjust_offset` writes a complete relative Frame, and the caller combines +both its translation and orientation with the body's current Frame.** A +`Vector3`-only port loses pitch/roll and is not equivalent. ### Step-by-step retail flow: @@ -238,7 +254,7 @@ if (this->keep_heading != 0) { } // ---- Output ---- -Frame::operator=(arg2, &delta_frame); // OUT: arg2 = translation-only Frame +Frame::operator=(arg2, &delta_frame); // OUT: complete relative Frame ``` ### NOTE on `progress_quantum` @@ -456,7 +472,7 @@ if (CPhysicsObj::SetPositionSimple(po, &target, 1) == OK_SPE) { | **Stall fail action** | Increments fail counter; only blips when threshold exceeded INSIDE adjust_offset | Calls `NodeCompleted(0)` on stall and pops the head; UseTime does the actual blip | **Architectural gap.** Retail's NodeCompleted(0) on stall pops the head node (advancing the queue) — useful when the head is unreachable but later nodes might be. Our port leaves the head in place. This means a single bad waypoint can cause repeated 5-frame failures rather than skipping past it. | | Blip target on threshold | `_queue.Last.TargetPosition` (tail) | UseTime: tail OR blipto_position OR last-pos-before-velocity-tail | Mostly OK for our use case (only type-1 nodes). | | `transient_state & 1` gate | None | Required | Probably safe in our codebase; file as follow-up. | -| AdjustOffset return shape | `Vector3` delta | In-place mutate `Frame*` (translation-only Frame) | Functionally equivalent — caller composes with current position either way. | +| AdjustOffset return shape | `Vector3` delta | In-place mutate complete `Frame*` | **Historical R6 gap, fixed 2026-07-19.** The old shape lost target-relative orientation; production now uses `MotionDeltaFrame`. | ### Concrete actionable changes diff --git a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md index cd73d4c0..5d1c0fa8 100644 --- a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md +++ b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md @@ -44,9 +44,9 @@ stage; **LOW** = dormant/textual. | G3 | **update_internal boundary model: clamp-to-`high_frame`/`low_frame` + leftover-time carry, boundary test `floor(frameNum) > high_frame` (i.e. ≥ high+1).** acdream tests `newPos >= maxBoundary - FrameEpsilon` and clamps `_framePosition = maxBoundary - FrameEpsilon` — epsilon-shifted boundary, different clamp target, no strict retail equivalence at exact-integer landings. This is the #61 "link→cycle boundary flash" bug class. | `0x005255d0` body (lines 301839-302235); ACE `Sequence.cs:366-377` (fwd), `:394-406` (rev) | `AnimationSequencer.cs:894,900` (`maxBoundary - FrameEpsilon`) | **BLOCKER** | | G4 | **`safety=64` loop cap** — retail has none; termination comes from `frameTimeElapsed` shrinking + the `frametime == 0` branch returning. Mandate says delete bandaids on cutover. | ACE `Sequence.cs:351-443` (no cap); decomp `0x005255d0` (while-true loop) | `AnimationSequencer.cs:872-874` | MED (delete in R1 core) | | G5 | **AnimDone gate is a LIST-STRUCTURE test, not a node flag.** Retail queues the global `anim_done_hook` singleton when `animDone && hook_obj != null && (anim_list.head_ − 4) != first_cyclic` — i.e. "the old head has been consumed and we're not already in the cyclic tail". acdream pushes `AnimationDoneSentinel` gated on `!_currNode.Value.IsLooping` — a per-node flag that acdream itself invented (retail nodes have no IsLooping). Different gate ⇒ different MotionDone timing. R2's `CheckForCompletedMotions → AnimationDone → MotionDone` chain consumes this signal; it must be retail-exact in R1. | `0x00525943-0x00525968` (verified raw this pass); AnimDoneHook singleton at data `0x0081d9fc`, Execute `0x00526c20` → `Hook_AnimDone 0x0050fda0` → `CPartArray::AnimationDone(1)` (§18a) | `AnimationSequencer.cs:1129` (`AnimationDoneSentinel`), Advance's `!IsLooping` gate (r1-acdream map row "AnimationDone hook") | **HIGH** | -| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); `CPhysicsObj::process_hooks` executes them inside `UpdatePositionInternal`, before the transition and manager tail, then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar. R6 processes semantic `AnimationDone` at capture time while retaining pose-dependent presentation delivery until current root/part poses are published. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18); corrected order pinned in `2026-07-19-r6-update-object-order-pseudocode.md` | `AnimationSequencer.cs` pending-hook queue; `AnimationHookFrameQueue.Capture` semantic completion and `Drain` presentation delivery | RETIRED by R6 semantic-order cutover 2026-07-19 | -| G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it | -| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null` → `apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED | +| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); `CPhysicsObj::process_hooks` executes the complete stream inside `UpdatePositionInternal`, before the transition and manager tail, then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar. R6 processes semantic `AnimationDone` at capture time while retaining every other pose-dependent hook until current root/part/equipped-child poses are published. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18); corrected order pinned in `2026-07-19-r6-complete-root-frame-pseudocode.md` | `AnimationSequencer.cs` pending-hook queue; `AnimationHookFrameQueue.Capture` semantic completion and `Drain` presentation delivery | **PARTIAL:** AnimationDone semantic order retired 2026-07-19; non-AnimationDone delivery remains TS-50 | +| G7 | **Frame-target root motion.** `update(quantum, Frame*)` accumulates literal velocity and omega plus authored PosFrames into one Frame. Local and remote movement now pass that complete Frame through `PositionManager`, `Frame::combine`, and collision; command-derived translation and manual rotation consumers are gone. | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0`; `Frame::combine 0x005122E0` | `CSequence.ApplyPhysics`; `AnimationSequencer.Advance(dt, Frame)`; `MotionDeltaFrame`; `PlayerMovementController`; `RemotePhysicsUpdater`; `2026-07-19-r6-complete-root-frame-pseudocode.md` | **RETIRED 2026-07-19** | +| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` is empty and `frame != null` → `apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no animation). The ported `CSequence.Update` implements that branch and `AnimationSequencer.Advance(dt, Frame)` exposes it to the ordinary-object scheduler. | `0x00525b80` (line 302402, §22) | `CSequence.Update`; `AnimationSequencer.Advance(dt, Frame)`; empty-list/full-Frame scheduler conformance tests | **RETIRED 2026-07-19** | | G9 | **`advance_to_next_animation` uses elapsed-direction plus node-framerate sign gates.** For elapsed ≥ 0, subtract the outgoing pose only when its framerate is negative, step Next/wrap `first_cyclic`, and combine the incoming pose only when its framerate is positive. For elapsed < 0, subtract when outgoing framerate is non-negative, step Previous/wrap list tail, and combine when incoming framerate is negative. Physics inside a selected pose operation uses the separate strict `abs(framerate) > F_EPSILON` gate. Matching v11.4186 assembly and ACE agree; the earlier “four unconditional pose ops” cleanup was wrong. | `0x005252B0`; matching assembly audit 2026-07-19; `r1-ace-sequence.md:70-86` | `CSequence.AdvanceToNextAnimation` + exact-boundary/sign conformance tests | **RETIRED 2026-07-19** | | G10 | **`append_animation` slides `first_cyclic` to the just-appended node on EVERY call.** Retail's "cyclic tail" is always exactly the LAST appended anim (so a multi-anim cycle MotionData loops only its final AnimData node once earlier ones are consumed). acdream sets `_firstCyclic` to the FIRST node of the cycle MotionData. Also retail: `if (curr_anim == null) { curr_anim = head; frame_number = get_starting_frame(head); }` — acdream's equivalents are scattered through `SetCycle`'s rebuild. | `0x00525510` (line 301777, §24) | `AnimationSequencer.cs:634-645` region + `EnqueueMotionData` (r1-acdream map rows "Node list", "add_motion") | **HIGH** — divergent loop membership for multi-anim cycles; also the retail invariant that makes remove_cyclic/apricot correct | | G11 | **The remove-family with curr_anim snap semantics is missing.** Retail: `remove_cyclic_anims` (0x00524e40) deletes `first_cyclic`→tail, snapping `curr_anim` back to prev + `frame_number = get_ending_frame(prev)` (or 0), then `first_cyclic = tail`; `remove_link_animations(n)` (0x00524be0) / `remove_all_link_animations` (0x00524ca0) delete predecessors of `first_cyclic`, snapping `curr_anim` FORWARD to `first_cyclic` + `get_starting_frame`; `clear_animations` (0x00524dc0) full wipe; `apricot` (0x00524b40) trims consumed leading nodes after every update, bounded by `curr_anim`/`first_cyclic`. acdream instead has `ClearCyclicTail` + wholesale queue clears + the invented "stale-head `_currNode` force-relocation" + "Fix B link-skip" — all approximations of what the retail remove-family + apricot do naturally. | lines 301258/301060/301128/301207/300978 (§5-8, §20) | `AnimationSequencer.cs:1311` (`ClearCyclicTail`), `:511`, stale-head relocation + Fix B blocks in `SetCycle` (r1-acdream map rows "Stale-head handling", "Fix B") | **HIGH** — these retire two invented mechanisms | @@ -79,7 +79,7 @@ divergence-register row when touched. | Per-frame crossing walk fires pose+hooks for EVERY integer frame crossed, strict ascending (fwd) / descending (rev) order | `0x005255d0` do/while loops (lines 302006-302056 + reverse mirror) | `AnimationSequencer.cs:910-941` (fwd `lastFrame++` w/ Forward, rev `lastFrame--` w/ Backward) | | Forward node wrap to `first_cyclic` (loop-the-cycle mechanism) | `0x005252b0` @0x005253xx: `GetNext==null → first_cyclic` | `AnimationSequencer.cs:1350-1358` | | Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | Matching v11.4186 assembly `0x00525982-0x00525990` copies `frameTimeElapsed` back then loops; ACE `Sequence.cs:436-442` agrees | `CSequence.UpdateInternal` assigns `timeElapsed = frameTimeElapsed` before looping | -| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `ApplyPosFrame(node, idx, reverse:)` fwd/conjugate-reverse (r1-acdream map row "Root motion") — values correct, TARGET wrong (G7: accumulator never drained) | +| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `CSequence` writes the caller-supplied root Frame; local and remote physics consume that same Frame (G7 retired) | | `frame_number` floored to int for pose lookup (`get_curr_animframe`/`get_curr_frame_number` shape) | `0x00524970`/`0x005249d0` (§15/17) | `AnimationSequencer.cs:884` (`(int)Math.Floor(_framePosition)`) | | `clear_physics` zeroing before rebuild | `0x00524d50` + `GetObjectSequence`'s `clear_physics; remove_cyclic_anims` pairing @0x005229cf etc. | `ClearPhysics()` called from `SetCycle` (r1-acdream map row "clear_physics") | | `AnimData` speed scaling: only framerate × speed, low/high pass through | `operator* 0x00525d00` (invoked from `add_motion` @0x0052255b) | `LoadAnimNode` (`AnimData.Framerate * speedMod`) | diff --git a/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md b/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md new file mode 100644 index 00000000..b81635fc --- /dev/null +++ b/docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md @@ -0,0 +1,408 @@ +# R6 complete root Frame cutover — retail pseudocode + +Date: 2026-07-19 + +Scope: the translation and orientation emitted by `CSequence` and consumed by +`CPhysicsObj::UpdatePositionInternal`. This note replaces the former acdream +split between animation root translation and command-derived/manual rotation. + +## Named retail oracle + +- `Frame::combine` `0x005122E0` (`acclient_2013_pseudo_c.txt:280355`) +- `Frame::set_rotate` `0x00535080` +- `Frame::IsValid` `0x00534ED0` +- `Position::subtract2` (complete relative Position/Frame) +- `CPhysicsObj::UpdatePositionInternal` `0x00512C30` (`:280817`) +- `CPhysicsObj::UpdateObjectInternal` `0x005156B0` +- `CPhysicsObj::SetPositionInternal(CTransition const*)` `0x00515330` +- `CPhysicsObj::change_cell` `0x00513390` +- `CPhysicsObj::remove_shadows_from_cells` `0x00511230` +- `CPhysicsObj::add_shadows_to_cells` `0x00514AE0` +- `CPhysicsObj::update_object` `0x00515D10` +- `CPhysicsObj::prepare_to_enter_world` `0x00511FA0` +- `CPhysicsObj::set_active` `0x0050FC40` +- `CPhysicsObj::animate_static_object` `0x00513DF0` +- `CPhysics::UseTime` `0x00509950` (separate static-object loop) +- `CPartArray::Update` `0x00517DB0` (`:285883`) +- `add_motion` `0x005224B0` (`:298437`) +- `CSequence::apply_physics` `0x00524AB0` (`:300955`) +- `InterpolationManager::InterpolateTo` `0x00555B20` +- `InterpolationManager::adjust_offset` `0x00555D30` + +The installed retail portal DAT is a second executable fixture. Motion table +`0x09000001` (Humanoid), global `TurnRight` modifier `0x0000000D`, has +`HasOmega` and literal omega `(0, 0, -1.5)` radians/second. It is not +`-pi/2`, and it is not absent. The fixture is pinned by +`HumanoidMotionTableRootMotionTests`. + +ACE cross-reference: +`docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md` records the +ACE `MotionTable`/`Sequence` implementation: modifier lookup tries the styled +key then the unstyled key, and `add_motion` writes +`MotionData.Velocity * speed` plus `MotionData.Omega * speed`. ACE agrees with +the named client here. Retail remains the ordering and transform oracle. + +## Pseudocode + +```text +add_motion(sequence, motionData, speed): + if motionData is null: + return + + sequence.velocity = motionData.velocity * speed + sequence.omega = motionData.omega * speed + for animation in motionData.animations: + sequence.append_animation(animation * speed) + +apply_physics(sequence, frame, magnitudeQuantum, signSource): + signedQuantum = abs(magnitudeQuantum) + if signSource < 0: + signedQuantum = -signedQuantum + + frame.origin += sequence.velocity * signedQuantum + frame.rotate(sequence.omega * signedQuantum) + +CPartArray.Update(dt, localFrame): + sequence.update(dt, localFrame) + +CPhysicsObj.prepare_to_enter_world(now): + update_time = now + remove object and children from lost/destroy queues + if not Static: + transient_state.Active = true + +CPhysicsObj.update_object(now): + if parent exists or cell is null or Frozen: + transient_state.Active = false + return without consuming update_time + + if player_object exists: + player_vector = offset(player.position, object.position) + player_distance = length(player_vector) + if player_distance <= 96 world units or partArray is null: + set_active(true) + else: + transient_state.Active = false + // With no player_object, retain the prior Active state. + + elapsed = now - update_time + if elapsed <= F_EPSILON or elapsed > HugeQuantum: + update_time = now + return + + while elapsed > MaxQuantum: + UpdateObjectInternal(MaxQuantum) + update_time += MaxQuantum + elapsed -= MaxQuantum + + if elapsed > MinQuantum: + UpdateObjectInternal(elapsed) + update_time = now + // A remainder at or below MinQuantum stays pending. + +CPartArray.InitDefaults(setup): + if setup.DefaultAnimation != 0: + sequence.clear_animations() + sequence.append_animation( + animation = setup.DefaultAnimation, + low = 0, + high = -1, + framerate = 30) + owner.InitDefaults(setup) + +CPhysicsObj.InitDefaults(setup): + if setup.DefaultScript != 0: + play_script_internal(setup.DefaultScript) + install setup default motion/sound/physics-script tables + if state has Static: + if setup.DefaultAnimation != 0: + state |= HasDefaultAnim + if setup.DefaultScript != 0: + state |= HasDefaultScript + if state has HasDefaultAnim or HasDefaultScript: + CPhysics.AddStaticAnimatingObject(this) + +CPhysicsObj.animate_static_object(now): + if cell is null: + return + elapsed = now - update_time + if elapsed <= F_EPSILON or elapsed > 2 seconds: + update_time = now + return + if partArray exists: + if state has HasDefaultAnim: + partArray.Update(elapsed, null) + // Retail passes the stored vector directly; no elapsed multiply. + position.frame.grotate(angularVelocity) + UpdatePartsInternal() + UpdateChildrenInternal() + if state has HasDefaultScript and scriptManager exists: + scriptManager.UpdateScripts() + if particleManager exists: + particleManager.UpdateParticles() + process_hooks() + update_time = now + +CPhysics.UseTime(now): + for object in ordinary object table: + object.update_object(now) + for object in static_animating_objects: + object.animate_static_object(now) + // These are separate ownership/workset paths. InitDefaults registers any + // physics-Static object carrying Setup DefaultAnimation/DefaultScript, + // including a server-created live object. + +CPhysicsObj.UpdatePositionInternal(dt, outputWorldFrame): + localFrame = identity + + if not Hidden: + if partArray exists: + partArray.Update(dt, localFrame) + + if OnWalkable: + localFrame.origin *= objectScale + else: + localFrame.origin *= 0 + // Orientation is never cleared or scaled by this gate. + + if positionManager exists: + positionManager.adjust_offset(localFrame, dt) + + outputWorldFrame = combine(position.frame, localFrame) + + if not Hidden: + UpdatePhysicsInternal(dt, outputWorldFrame) + + process_hooks() + +Frame.combine(output, current, delta): + output.origin = current.origin + rotate(current.orientation, delta.origin) + output.set_rotate(current.orientation * delta.orientation) + +Frame.set_rotate(candidate): + previous = orientation + orientation = normalize(candidate) + if not Frame.IsValid(): + orientation = previous + +InterpolationManager.adjust_offset(delta, dt): + if no Position node or body is not in Contact: + return without mutating delta + relative = Position.subtract2(target, current) + relative.origin = clamp_to_catchup_distance(relative.origin, dt) + if manager.keep_heading: + relative.frame.set_heading(0) + delta = relative.frame // replaces both origin and orientation + +CPhysicsObj.SetPositionInternal(transition): + if transition.current_cell is null: + prepare_to_leave_visibility() + store_position(transition.current_position) + object_maintenance.goto_lost_cell() + clear Active + return + + if current cell differs: + change_cell(transition.current_cell) + else: + commit the new full objcell id to the object, PartArray, and children + set_frame(transition.current_position.frame) + commit contact plane, OnWalkable, sliding normal, and collision callbacks + if transition has a non-empty crossed-cell array: + remove_shadows_from_cells() + add_shadows_to_cells(transition.cell_array) +``` + +The ordering consequence is important: a delta translates through the old +orientation and only then applies its relative rotation. A large elapsed frame +must run multiple complete object quanta in time order. A render fragment below +the strict minimum does not advance CSequence at all; it stays in the object +clock until a later admitted quantum. + +## Port mapping + +- `MotionDeltaFrame.Combine` implements the retail composition and scales only + the incoming origin. +- One incarnation-stable `RetailObjectQuantumClock` is shared by local, + remote, Hidden, and projectile components of each live object. It admits + complete object quanta; the local animation callback writes one reusable + `MotionDeltaFrame` only after admission. `prepare_to_enter_world` clears the + retained fragment and immediately restores Active only for non-Static + objects, so their next elapsed frame can run; Static objects retain the + inactive state established by initialization/leave-visibility. It does not + invent a second reactivation frame. +- `CPartArray::InitDefaults` installs Setup DefaultAnimation for every + setup-backed PartArray, independent of Static. Static controls only the + additional `static_animating_objects -> animate_static_object` workset. + Both DAT-hydrated statics and server-created objects whose final physics + state is Static enter that workset when their Setup carries a + DefaultAnimation. Default animation bypasses the MotionTable and installs + the Setup animation directly over `0..last` at 30 frames/second. A short + cell-less interval remains on the owner's clock and catches up after + re-entry; an interval above two seconds is rebased and discarded. Script-only + static owners still use acdream's shared PhysicsScriptRunner and are covered + by TS-51 rather than entering an empty animation scan. + A live static workset entry binds the exact PartArray and PhysicsBody already + owned by its `LiveEntityRecord`; it never constructs a second sequencer or + body. The raw angular-velocity vector is applied without multiplying by + elapsed time, exactly as the named function does. A changed root commits the + entity, effect pose, and multipart collision shadow together. Zero omega does + not trigger a collision reflood, and a Hidden/nonresident object cannot have + its suspended shadow restored by this animation path. +- Animation hooks are captured after `UpdatePhysicsInternal` and before the + transition/manager tail. Semantic `AnimationDone` executes there, matching + `CPhysicsObj::process_hooks`. Other pose-dependent router sinks remain + deferred until final root/part/equipped-child pose publication; that known + residual is registered as TS-50 rather than claimed as exact ordering. + Static owners retain their semantic hook tail until final root, part, and + attached-child publication, matching `animate_static_object`'s later + `process_hooks` call. A residency change invalidates both the prepared pose + and that pending tail. +- Retail session dispatch and `CPhysics::UseTime` are single-threaded FIFO + operations. App therefore wraps complete packet and object-frame operations + in `RetailInboundEventDispatcher`: a callback-triggered packet queues behind + the current operation instead of recursively interleaving with its tail. + Position, State, Vector, and Movement keep separate authority versions; + Position/Vector/Movement additionally share only a velocity-write version. + Never replace these with one global body-mutation token: that incorrectly + discards independent accepted channels (for example State during Position). + The direct packet/frame path carries state into a cached static callback and + allocates nothing; only genuinely nested work receives a retained FIFO node. +- Projectile `BeginQuantum` is candidate-only. It restores the canonical begin + frame/dynamics before returning; `CompleteQuantum` may commit only while the + exact live record, projectile runtime, entity, spatial owner, and prediction + version remain current. Any accepted Position, Vector, or State invalidates + the candidate without rolling back the authoritative mutation. +- Animation-hook batches capture the pose owner's monotonic lifetime before + running semantic AnimationDone. Capture rechecks it before every completion, + and drain rechecks that lifetime and root pose before every routed hook, + because either AnimationDone or an earlier sink can delete the owner and + reuse the same local ID synchronously. +- Remote animation advances into one reusable DAT `Frame`; both components are + passed to `RemotePhysicsUpdater` and applied through the same + `PositionManager` frame. +- A body-backed animated object without a MovementManager now commits its + complete Frame through `LiveEntityOrdinaryPhysicsUpdater`: root translation + is admitted only while OnWalkable, orientation remains live, the resulting + candidate runs through Transition/SetPositionInternal, and the same record is + rebucketed without recreating resources. Callback deletion/replacement is + revalidated before every commit. +- The resolved body and `WorldEntity` root/full-cell frame are committed before + the canonical rebucket callback, matching SetPositionInternal's frame/contact + commit before its final shadow replacement. A loaded-to-pending rebucket (or + same-GUID replacement during its visibility callback) invalidates the caller + before shadow or manager-tail publication. Non-projectile shadow residency is + symmetric in `LiveEntityPresentationController`, including the pending-first + case whose initial false edge precedes collision registration; projectiles + retain their dedicated residency owner. +- Local projection and both authoritative remote UpdatePosition tails use the + same post-rebucket exact-owner gate. Remote collision refresh is forced by a + cell change or by a changed complete pose (translation or sign-invariant + quaternion), so offset/multipart Setup shapes rotate in place and a same-pose + cell crossing cannot retain an old flood seed. +- A retained projectile remains the same CPhysicsObj when Missile clears. With + no MovementManager it continues through the ordinary one-sphere transition + owner; with a MovementManager the owner transfers exactly once to remote + motion. Projection re-entry restores its retained collision shadow on the + visibility edge, before any later simulation quantum. +- Active interpolation preserves the target's complete quaternion and replaces + the PartArray Frame. The already-close `InterpolateTo` branch applies only + the target heading, matching `CPhysicsObj::set_heading`. `keep_heading` is + manager-wide, not a per-node flag; each near enqueue replaces it. +- `FrameOps.SetRotate` rejects zero, non-finite, and otherwise invalid + quaternion candidates by restoring the prior orientation. +- Spawn, teleport, correction, and outbound movement/position serialization + read the authoritative body quaternion directly; none round-trips through + the horizontal `Yaw` projection. +- `MotionTableDispatchSink` only dispatches motion-table commands. It no longer + reports turn callbacks. +- `RemoteMotion.ObservedOmega`, the player/NPC multiplication-order fork, and + `AnimationSequencer.SetCycle`'s synthetic `pi/2` turn omega are deleted. +- A controller deliberately constructed without a `PartArray` retains a + Humanoid-only fallback at the DAT-pinned `1.5` radians/second. A normal GUI + client does not enter that path. + +## Conformance gates + +- `MotionDeltaFrameTests`: exact `FrameOps.Combine` equivalence, scale affects + origin only, and temporal turn-then-move composition. +- `RetailObjectQuantumClockTests`: strict bounds, retained remainder, + MaxQuantum subdivision, HugeQuantum discard, `set_active` rebasing, and + Static-aware `prepare_to_enter_world` reset. +- `RetailObjectActivityGateTests` and `LiveEntityRuntimeTests`: the 96-unit + PartArray gate, parent/cell/Frozen suspension, loaded rebucket retention, + pending re-entry rebasing, body/clock Active synchronization, and GUID-reuse + clock isolation. +- `LiveEntityAnimationSchedulerTests`: the canonical live-object workset also + advances objects without a render animation, consumes their queued near + UpdatePosition correction, applies complete generic root + Frames, transfers a retained missile body exactly once when classification + clears, and stops catch-up immediately when a callback deletes, withdraws, + or replaces an incarnation. +- `RetailStaticAnimatingObjectSchedulerTests` and + `AnimationSequencerTests`: unconditional Setup DefaultAnimation installation, + DAT/live physics-Static workset membership, direct 30-fps playback, + short-residency-gap catch-up, canonical PartArray/body reuse, raw (not + elapsed-scaled) omega, synchronized multipart shadow rotation, zero-omega + no-op commits, callback-safe removal, and the retail greater-than-two-second + discard. `StaticLiveRootCommitter` coverage proves Hidden omega cannot + resurrect a suspended shadow. +- `RemoteInboundMotionDispatcherTests`: one animation-optional owner preserves + retail packet-head/style/type-0/sticky ordering with a null PartArray sink; + unknown movement types execute the head and never enter the case-0 funnel; + supersession during interrupt prevents every later packet side effect. +- `LiveEntityOrdinaryPhysicsUpdaterTests`: complete root Frames cross cells and + landblocks through Transition, while hook-side deletion prevents stale body, + shadow, pose, or spatial commits. +- `ProjectileControllerTests`: pending-cell hydration restores the same body's + Active/InWorld/shadow residency immediately and does not replay logical + creation; the destination cell is canonical before suspension and after + hydration, and authoritative Position/Vector/State between prediction halves + discards only the stale candidate. +- `RetailInboundEventDispatcherTests`, `LiveEntityPresentationControllerTests`, + `RemoteTeleportControllerTests`, and `EntityEffectPoseRegistryTests`: nested + packet arrival drains after the complete older tail, independent State and + Position both survive resolver callbacks, newer Position supersedes older + placement, Hidden-to-Visible recursion remains FIFO, and hook batches cannot + cross deletion/local-ID reuse during capture or drain. The FIFO's nonnested + state path is pinned at zero allocations after warmup. +- `PlayerMovementControllerTests`: full-frame local composition, large-frame + equivalence, hidden/airborne gates, hook cadence, and proof that + animation-backed turns are not integrated twice. +- `InterpolationManagerTests`: complete relative target orientation, + keep-heading, and already-close heading-only behavior. +- `RemotePhysicsUpdaterTests` and `LiveEntityPresentationControllerTests`: + translation uses the pre-turn orientation and the same frame then rotates the + body; cross-cell loaded-to-pending commits the destination root before the + callback boundary, suspends rather than re-adds collision, restores on + hydration, and isolates old shadows across callback GUID reuse. Direct + authoritative rebuckets and initial-pending materialization cover the same + symmetric collision-residency contract; same-position rotation and cell-only + changes refresh collision while unchanged/sign-flipped quaternion poses do + not reflood. +- `LiveEntityRuntimeTests`: update iteration and render-ID classification copy + only concrete spatial animation owners through reusable storage with zero + steady-state allocations. +- `MotionTableDispatchSinkTests`: Branch-4 turn preserves the locomotion cycle, + contributes literal DAT omega, and unwinds on stop. +- `RemoteChaseEndToEndHarnessTests`: turn, chase, re-arm, action completion, and + sticky following run through the complete Frame plus retail manager tail. +- `HumanoidMotionTableRootMotionTests`: installed retail walk displacement and + exact Humanoid `TurnRight` omega `-1.5` radians/second. +- `MovementManagerTests` and `PlayerMovementControllerTests`: + `PerformMovement` invokes `set_active(1)` before request validation, while a + Static physics object preserves retail's no-op activation behavior. + +## Registered residual + +The ordinary/static object worksets and their root/animation clocks are now +retail-shaped, but acdream's shared particle and PhysicsScript managers still +advance once per render frame after every owner has run. Retail advances those +tails inside each admitted object update (and inside +`animate_static_object`). This timing difference is deliberately registered as +TS-51; it is not part of the R6 conformance claim. + +Final automated gate (2026-07-20): three independent retail-conformance, +architecture/integration, and adversarial re-reviews reported no actionable +finding after correction; Release build completed with zero warnings; the full +solution passed 6,446 tests with 5 intentional skips. diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.App/Input/LocalPlayerOutboundController.cs index bff58f9b..daff2089 100644 --- a/src/AcDream.App/Input/LocalPlayerOutboundController.cs +++ b/src/AcDream.App/Input/LocalPlayerOutboundController.cs @@ -11,15 +11,11 @@ namespace AcDream.App.Input; /// public sealed class LocalPlayerOutboundController { - private readonly Func _toWireRotation; private readonly Action _diagnostic; public LocalPlayerOutboundController( - Func toWireRotation, Action diagnostic) { - _toWireRotation = toWireRotation - ?? throw new ArgumentNullException(nameof(toWireRotation)); _diagnostic = diagnostic ?? throw new ArgumentNullException(nameof(diagnostic)); } @@ -38,14 +34,13 @@ public sealed class LocalPlayerOutboundController if (session is null || hidden) return; - Quaternion wireRotation = _toWireRotation(controller.Yaw); - if (!controller.TryGetOutboundPosition( - wireRotation, - out uint wireCellId, - out Vector3 wirePosition)) + if (!controller.TryGetOutboundPosition(out Position outboundPosition)) { return; } + uint wireCellId = outboundPosition.ObjCellId; + Vector3 wirePosition = outboundPosition.Frame.Origin; + Quaternion wireRotation = outboundPosition.Frame.Orientation; if (movement.ShouldSendMovementEvent) TrySendMovement(session, controller, movement); @@ -82,16 +77,14 @@ public sealed class LocalPlayerOutboundController if (session is null || hidden) return; - Quaternion wireRotation = _toWireRotation(controller.Yaw); - if (!controller.TryGetOutboundPosition( - wireRotation, - out uint wireCellId, - out Vector3 wirePosition)) + if (!controller.TryGetOutboundPosition(out Position position)) { return; } + uint wireCellId = position.ObjCellId; + Vector3 wirePosition = position.Frame.Origin; + Quaternion wireRotation = position.Frame.Orientation; - var position = new Position(wireCellId, wirePosition, wireRotation); if (!controller.ShouldSendPositionEvent( position, controller.ContactPlane, @@ -136,14 +129,13 @@ public sealed class LocalPlayerOutboundController if (session is null || controller is null) return false; - Quaternion wireRotation = _toWireRotation(controller.Yaw); - if (!controller.TryGetOutboundPosition( - wireRotation, - out uint wireCellId, - out Vector3 wirePosition)) + if (!controller.TryGetOutboundPosition(out Position outboundPosition)) { return false; } + uint wireCellId = outboundPosition.ObjCellId; + Vector3 wirePosition = outboundPosition.Frame.Origin; + Quaternion wireRotation = outboundPosition.Frame.Orientation; byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0; RawMotionState rawMotionState = BuildRawMotionState(movement); diff --git a/src/AcDream.App/Input/LocalPlayerProjectionController.cs b/src/AcDream.App/Input/LocalPlayerProjectionController.cs index 9b851bb8..0d5697bc 100644 --- a/src/AcDream.App/Input/LocalPlayerProjectionController.cs +++ b/src/AcDream.App/Input/LocalPlayerProjectionController.cs @@ -13,19 +13,27 @@ public sealed class LocalPlayerProjectionController private readonly Func _liveCenterY; private readonly Action _syncShadow; private readonly Action _rebucket; + private readonly Func _isCurrentVisibleProjection; + private readonly Action _suspendShadow; public LocalPlayerProjectionController( Func resolveEntity, Func liveCenterX, Func liveCenterY, Action syncShadow, - Action rebucket) + Action rebucket, + Func isCurrentVisibleProjection, + Action suspendShadow) { _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity)); _liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX)); _liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY)); _syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow)); _rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket)); + _isCurrentVisibleProjection = isCurrentVisibleProjection + ?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection)); + _suspendShadow = suspendShadow + ?? throw new ArgumentNullException(nameof(suspendShadow)); } public void Project( @@ -39,11 +47,10 @@ public sealed class LocalPlayerProjectionController entity.SetPosition(movement.RenderPosition); entity.ParentCellId = movement.CellId; - entity.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( - System.Numerics.Vector3.UnitZ, - controller.Yaw - MathF.PI / 2f); - if (!hidden) - _syncShadow(entity, movement.CellId); + // Retail's root is a complete Frame. Project the authoritative body + // quaternion directly so pitch/roll from a server correction or DAT + // root frame cannot be flattened by the presentation layer. + entity.Rotation = controller.BodyOrientation; uint currentLandblock; if (movement.CellId != 0 && (movement.CellId & 0xFFFFu) >= 0x0100u) @@ -62,7 +69,22 @@ public sealed class LocalPlayerProjectionController // The teleport owner alone projects the destination while the local // controller deliberately retains its frozen source cell. - if (controller.State != PlayerState.PortalSpace) - _rebucket(entity.ServerGuid, currentLandblock); + if (controller.State == PlayerState.PortalSpace) + return; + + // SetPositionInternal commits the complete root before changing cell + // membership, then replaces collision rows only while the object still + // owns a loaded cell. Rebucket is a synchronous callback boundary: it + // can move this incarnation to pending or replace the GUID entirely. + _rebucket(entity.ServerGuid, currentLandblock); + if (hidden + || movement.CellId == 0 + || !_isCurrentVisibleProjection(entity)) + { + _suspendShadow(entity); + return; + } + + _syncShadow(entity, movement.CellId); } } diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index d97c72f8..7f6cbf00 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -68,10 +68,10 @@ public readonly record struct MovementResult( bool JustLanded = false, // true on the single frame we transitioned airborne → grounded float? JumpExtent = null, // non-null when a jump was triggered this frame Vector3? JumpVelocity = null, // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast. - // Retail updates mouse-origin turn motions continuously but sends the - // resulting movement state only on ToggleMouseLook start/stop and the - // CameraSet half-second cadence. Keep packet ownership separate from the - // local motion-state change so mouse sampling cannot flood the server. + // Retail updates mouse-origin turn motions continuously but sends the + // resulting movement state only on ToggleMouseLook start/stop and the + // CameraSet half-second cadence. Keep packet ownership separate from the + // local motion-state change so mouse sampling cannot flood the server. bool ShouldSendMovementEvent = false, // CameraSet::Rotate always calls MovePlayer with holdRun=true for the turn // axis, independently of the player's ordinary walk/run toggle. @@ -156,12 +156,39 @@ public sealed class PlayerMovementController /// public PlayerState State { get; set; } = PlayerState.InWorld; - public float Yaw { get; set; } + /// + /// Horizontal projection of the authoritative body quaternion. Assigning + /// a yaw is the explicit retail Frame::set_heading seam and therefore + /// intentionally replaces pitch/roll; ordinary object ticks never rebuild + /// the quaternion from this projection. + /// + public float Yaw + { + get => AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading( + AcDream.Core.Physics.Motion.MoveToMath.GetHeading(_body.Orientation)); + set + { + float wrapped = value; + while (wrapped > MathF.PI) wrapped -= 2f * MathF.PI; + while (wrapped < -MathF.PI) wrapped += 2f * MathF.PI; + _body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( + _body.Orientation, + AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(wrapped)); + } + } public Vector3 Position => _body.Position; public Vector3 RenderPosition => ComputeRenderPosition(); public uint CellId { get; private set; } public AcDream.Core.Physics.Position CellPosition => _body.CellPosition; + /// + /// True only when the most recent visible or Hidden object update admitted + /// at least one complete retail quantum. Presentation uses this to rebuild + /// a Hidden part pose after HandleEnterWorld without doing per-render-frame + /// work while the object clock is retaining a fragment. + /// + internal bool AdvancedObjectQuantumLastTick { get; private set; } + /// /// Returns retail's canonical outbound Position: the physics body's /// carried cell id plus its landblock-local frame origin. Retail @@ -171,14 +198,17 @@ public sealed class PlayerMovementController /// part of the protocol frame. /// internal bool TryGetOutboundPosition( - Quaternion rotation, - out uint cellId, - out Vector3 localOrigin) + out AcDream.Core.Physics.Position outboundPosition) { AcDream.Core.Physics.Position canonical = _body.CellPosition; - cellId = canonical.ObjCellId; - localOrigin = canonical.Frame.Origin; - return PositionFrameValidation.IsValid(cellId, localOrigin, rotation); + outboundPosition = new AcDream.Core.Physics.Position( + canonical.ObjCellId, + canonical.Frame.Origin, + _body.Orientation); + return PositionFrameValidation.IsValid( + outboundPosition.ObjCellId, + outboundPosition.Frame.Origin, + outboundPosition.Frame.Orientation); } /// @@ -324,11 +354,11 @@ public sealed class PlayerMovementController /// Exposed for the network outbound layer to stamp NotePositionSent. public float SimTimeSeconds => _simTimeSeconds; - // L.5 retail physics-tick gate (2026-04-30). + // R6 retail complete-object scheduler (2026-07-19). // - // Retail's CPhysicsObj::update_object subdivides per-frame dt into - // MinQuantum (1/30s) sized integration steps, SKIPPING entirely when - // accumulated dt is below MinQuantum. The retail debugger trace + // Retail's CPhysicsObj::update_object subdivides elapsed time into + // MaxQuantum-sized complete object updates plus a remainder, retaining + // that remainder while it is at or below MinQuantum. The debugger trace // confirmed this: UpdatePhysicsInternal fires only ~61% as often as // update_object — i.e., retail's effective physics tick rate is 30Hz // even when the renderer runs at 60+Hz. @@ -348,11 +378,16 @@ public sealed class PlayerMovementController // // ACE: PhysicsObj.UpdateObject (Physics.cs). // Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950). - private float _physicsAccum; + private readonly RetailObjectQuantumClock _objectClock; private Vector3 _prevPhysicsPos; private Vector3 _currPhysicsPos; - private Func? _advanceAnimationRootMotion; - private Vector3 _pendingAnimationRootMotion; + private Action? + _advanceAnimationRootMotion; + private Action? _processAnimationHooks; + private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame + _animationRootMotionScratch = new(); + private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame + _positionManagerDeltaScratch = new(); // ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ── // The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase, @@ -418,9 +453,12 @@ public sealed class PlayerMovementController /// public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; } - public PlayerMovementController(PhysicsEngine physics) + public PlayerMovementController( + PhysicsEngine physics, + RetailObjectQuantumClock? objectClock = null) { _physics = physics; + _objectClock = objectClock ?? new RetailObjectQuantumClock(); _body = new PhysicsBody { @@ -440,7 +478,7 @@ public sealed class PlayerMovementController // character's "I can clear that fence" hop) // Until #7 ships and PlayerDescription gives us the server's real // skill, this default is the right "feels like retail" baseline. - int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200; + int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200; int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300; _weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill); _motion = new MotionInterpreter(_body, _weenie); @@ -448,6 +486,7 @@ public sealed class PlayerMovementController // (retail CPhysicsObj::movement_manager); the moveto child binds // later via MoveToFactory (EnterPlayerModeNow / the test rigs). Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion); + Movement.ActivatePhysicsObject = ActivateFromMovement; // R3-W4 (A3): the local player's movement is input-driven — // movement_is_autonomous true so apply_current_movement's dual // dispatch routes apply_raw_movement (IsThePlayer && autonomous). @@ -455,6 +494,35 @@ public sealed class PlayerMovementController _body.LastMoveWasAutonomous = true; } + /// + /// Host half of retail MovementManager::PerformMovement's + /// unconditional CPhysicsObj::set_active(1) head. Static objects + /// reject activation; an inactive ordinary object rebases its canonical + /// object clock before setting the body's transient bit. + /// + private void ActivateFromMovement() + { + if ((_body.State & PhysicsStateFlags.Static) != 0) + return; + + _objectClock.Activate(); + _body.TransientState |= TransientStateFlags.Active; + } + + /// + /// Applies retail's parent/cell-less/Frozen early gate: clear Active and + /// advance only wall-clock presentation time. The next eligible frame + /// reactivates through set_active(1) and rebases the object clock. + /// + internal void SuspendObjectUpdate(float elapsedSeconds) + { + AdvancedObjectQuantumLastTick = false; + if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f) + _simTimeSeconds += elapsedSeconds; + _objectClock.Deactivate(); + _body.TransientState &= ~TransientStateFlags.Active; + } + /// /// Begins retail instant mouse-look. CameraSet::ToggleMouseLook /// (0x00457490) sends a movement event on both transitions, even before a @@ -834,11 +902,23 @@ public sealed class PlayerMovementController /// and OnWalkable must be present on the local physics body. internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable; - /// R4-V5: body orientation for the - /// manager's position seam (re-derived from every - /// Update — heading reads/writes go through Yaw, not this). + /// R4-V5: complete body orientation for the + /// manager's position seam. is + /// only its horizontal projection. internal Quaternion BodyOrientation => _body.Orientation; + /// + /// Install a complete authoritative frame rotation (spawn, teleport, or + /// server correction) without collapsing it through the yaw projection. + /// + internal void SetBodyOrientation(Quaternion orientation) + { + _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( + _body.Position, + _body.Orientation, + orientation); + } + /// R4-V5 wedge fix — the P1 unpack store /// (CPhysics::SetObjectMovement @00509730: /// last_move_was_autonomous = arg7, written on the unpack path @@ -885,15 +965,19 @@ public sealed class PlayerMovementController /// /// Binds the owning PartArray/CSequence update to the local physics tick. /// The callback runs after this frame's input edges have reached the motion - /// table and returns the complete local translation emitted by - /// CSequence::update. The controller accumulates sub-quantum deltas - /// and consumes them through the same PositionManager frame as retail. + /// table and writes the complete local Frame emitted by + /// CSequence::update. The callback runs only for admitted object + /// quanta; render-frame fragments are retained by the object clock rather + /// than advancing PartArray early. /// - public void AttachAnimationRootMotionSource(Func advance) + public void AttachAnimationRootMotionSource( + Action advance, + Action? processHooks = null) { _advanceAnimationRootMotion = advance ?? throw new ArgumentNullException(nameof(advance)); - _pendingAnimationRootMotion = Vector3.Zero; + _processAnimationHooks = processHooks; + _animationRootMotionScratch.Reset(); } /// @@ -970,10 +1054,10 @@ public sealed class PlayerMovementController System.Numerics.Plane contactPlane, float nowSeconds) { - _lastSentPosition = position; + _lastSentPosition = position; _lastSentContactPlane = contactPlane; - _lastSentTime = nowSeconds; - _lastSentInitialized = true; + _lastSentTime = nowSeconds; + _lastSentInitialized = true; } /// @@ -1050,7 +1134,9 @@ public sealed class PlayerMovementController UpdateCellId(_body.CellPosition.ObjCellId, "teleport"); // Treat as grounded after a server-side position snap. - _body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + _body.TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.Active; _body.Velocity = Vector3.Zero; // #145 Slice 7: idle the motion interpreter on a server snap / teleport arrival. @@ -1094,8 +1180,7 @@ public sealed class PlayerMovementController // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; - _physicsAccum = 0f; - _pendingAnimationRootMotion = Vector3.Zero; + _objectClock.ResetForEnterWorld(); } /// @@ -1109,13 +1194,15 @@ public sealed class PlayerMovementController _body.SnapToCell(cellId, pos, cellLocal); _prevPhysicsPos = pos; _currPhysicsPos = pos; - _pendingAnimationRootMotion = Vector3.Zero; UpdateCellId(_body.CellPosition.ObjCellId, "force-position"); } private Vector3 ComputeRenderPosition() { - float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f); + float alpha = Math.Clamp( + (float)(_objectClock.PendingSeconds / PhysicsBody.MinQuantum), + 0f, + 1f); return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); } @@ -1128,66 +1215,93 @@ public sealed class PlayerMovementController /// public MovementResult TickHidden(float dt, Action? handleTargeting = null) { - _simTimeSeconds += dt; - _physicsAccum = 0f; - _pendingAnimationRootMotion = Vector3.Zero; - Vector3 previousPosition = _body.Position; - bool previousContact = _body.InContact; - bool previousOnWalkable = _body.OnWalkable; - - if (PositionManager is { } manager) + AdvancedObjectQuantumLastTick = false; + if (!float.IsFinite(dt) || dt <= 0f) { - var delta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); - manager.AdjustOffset(delta, dt); - if (delta.Origin != Vector3.Zero) - _body.Position += Vector3.Transform(delta.Origin, _body.Orientation); - if (!delta.Orientation.IsIdentity) + return CapturePresentationResult() with { - Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading( - AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw) - + delta.GetHeading()); - _body.Orientation = Quaternion.CreateFromAxisAngle( - Vector3.UnitZ, - Yaw - MathF.PI / 2f); - } + RenderPosition = _body.Position, + IsOnGround = _body.OnWalkable, + }; } - if (_body.Position != previousPosition && CellId != 0 && _physics.LandblockCount > 0) + _simTimeSeconds += dt; + bool reactivated = _objectClock.Activate(); + _body.TransientState |= TransientStateFlags.Active; + RetailObjectQuantumBatch batch = reactivated + ? default + : _objectClock.Advance(dt); + AdvancedObjectQuantumLastTick = batch.Count > 0; + if (batch.Discarded) { - ResolveResult resolved = _physics.ResolveWithTransition( - previousPosition, - _body.Position, - CellId, - sphereRadius: 0.48f, - sphereHeight: 1.835f, - stepUpHeight: StepUpHeight, - stepDownHeight: StepDownHeight, - isOnGround: previousOnWalkable, - body: _body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - movingEntityId: LocalEntityId); - _body.CommitTransitionPosition(resolved.CellId, resolved.Position); - PhysicsObjUpdate.CommitSetPositionTransition( - _body, - resolved.InContact, - resolved.OnWalkable, - resolved.CollisionNormalValid, - resolved.CollisionNormal, - previousContact, - previousOnWalkable, - Movement.HitGround, - _motion.LeaveGround); - UpdateCellId(resolved.CellId, "hidden-position-manager"); + _prevPhysicsPos = _body.Position; + _currPhysicsPos = _body.Position; + } + + for (int qi = 0; qi < batch.Count; qi++) + { + float quantum = batch.GetQuantum(qi); + Vector3 previousPosition = _body.Position; + bool previousContact = _body.InContact; + bool previousOnWalkable = _body.OnWalkable; + + if (PositionManager is { } manager) + { + var delta = _positionManagerDeltaScratch; + delta.Reset(); + manager.AdjustOffset(delta, quantum); + if (delta.Origin != Vector3.Zero) + _body.Position += Vector3.Transform(delta.Origin, _body.Orientation); + if (!delta.Orientation.IsIdentity) + { + _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( + _body.Position, + _body.Orientation, + _body.Orientation * delta.Orientation); + } + } + + // CPhysicsObj::process_hooks remains inside UpdatePositionInternal + // even when Hidden suppresses PartArray and physics advancement. + _processAnimationHooks?.Invoke(); + + if (_body.Position != previousPosition && CellId != 0 && _physics.LandblockCount > 0) + { + ResolveResult resolved = _physics.ResolveWithTransition( + previousPosition, + _body.Position, + CellId, + sphereRadius: 0.48f, + sphereHeight: 1.835f, + stepUpHeight: StepUpHeight, + stepDownHeight: StepDownHeight, + isOnGround: previousOnWalkable, + body: _body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: LocalEntityId); + _body.CommitTransitionPosition(resolved.CellId, resolved.Position); + PhysicsObjUpdate.CommitSetPositionTransition( + _body, + resolved.InContact, + resolved.OnWalkable, + resolved.CollisionNormalValid, + resolved.CollisionNormal, + previousContact, + previousOnWalkable, + Movement.HitGround, + _motion.LeaveGround); + UpdateCellId(resolved.CellId, "hidden-position-manager"); + } + + RetailObjectManagerTail.Run( + handleTargeting, + Movement, + _motion.CheckForCompletedMotions, + PositionManager); + _prevPhysicsPos = _body.Position; + _currPhysicsPos = _body.Position; } - RetailObjectManagerTail.Run( - checkDetection: null, - handleTargeting, - movementUseTime: Movement.UseTime, - partArrayHandleMovement: _motion.CheckForCompletedMotions, - positionUseTime: PositionManager is { } positionManager - ? positionManager.UseTime - : null); _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; _wasAirborneLastFrame = !_body.OnWalkable; @@ -1212,6 +1326,14 @@ public sealed class PlayerMovementController MovementInput input, Action? handleTargeting = null) { + AdvancedObjectQuantumLastTick = false; + // Reject a malformed host-frame duration at the controller boundary. + // The retail object clock cannot sanitize state that input/jump/yaw + // code already mutated; in particular Infinity would never converge + // in heading wrap. A rejected frame is a pure presentation read. + if (!float.IsFinite(dt) || dt <= 0f) + return CapturePresentationResult(); + _simTimeSeconds += dt; // Portal-space guard: while teleporting, no input is processed and @@ -1292,9 +1414,9 @@ public sealed class PlayerMovementController // the old level-triggered build, which always reflected the // currently-held set). if (input.Forward && !_prevForwardHeld) - { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; } + { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; } else if (input.Backward && !_prevBackwardHeld && !input.Forward) - { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; } + { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; } if (!input.Forward && _prevForwardHeld) { if (input.Backward) @@ -1304,7 +1426,7 @@ public sealed class PlayerMovementController motionEdgeFired = true; } else if (!input.Backward && _prevBackwardHeld && !input.Forward) - { StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; } + { StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; } // Sidestep channel. CameraInstantMouseLook remaps held keyboard // TurnLeft/TurnRight into this channel while MMB is active. @@ -1398,87 +1520,12 @@ public sealed class PlayerMovementController _prevRunHeld = input.Run; // Retail input reaches MotionTableManager before this object's - // CPartArray update. Advance only after the edge dispatch above so a - // Ready-to-Walk/Run link contributes its authored displacement on the - // same object tick. Sub-quantum render frames accumulate until the - // 30 Hz physics gate consumes them below. + // CPartArray update. The complete-object scheduler below advances it + // once per admitted retail quantum, after every input edge for this + // render frame has reached the motion table. bool hasAnimationRootMotion = _advanceAnimationRootMotion is not null; - if (_advanceAnimationRootMotion is { } advanceRootMotion) - { - Vector3 rootDelta = advanceRootMotion(dt); - if (_body.OnWalkable) - _pendingAnimationRootMotion += rootDelta * ObjectScale; - else - _pendingAnimationRootMotion = Vector3.Zero; - } - // ── 1. Apply turning from keyboard + mouse ──────────────────────────── - // 2026-05-16 — retail-faithful turn rate. - // Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt - // - CMotionInterp::apply_run_to_command 0x00527be0 - // multiplies turn_speed by run_turn_factor (1.5) under - // HoldKey.Run on TurnRight/TurnLeft commands. - // - Base rate ±π/2 rad/s comes from add_motion 0x005224b0 - // with HasOmega-cleared MotionData fallback. - // Effective: walking ≈ 90°/s, running ≈ 135°/s. - // Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally - // close to retail walking but no run differentiation. - // D6.2: local keyboard turn rate now comes from the interpreted turn - // state (adjust_motion normalized TurnLeft→TurnRight with sign + - // apply_run_to_command ×RunTurnFactor when running). omega.Z = - // BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote - // path uses (GameWindow ObservedOmega seed). Numerically identical to the - // former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged. - // R4-V5: runs during a moveto too — the manager's aux-turn steering - // and TurnToHeading nodes dispatch TurnRight/TurnLeft through - // _DoMotion into the SAME interpreted turn state, so this one - // integrator rotates the player for both input and moveto turns. - // Mouse-origin turns now enter the same interpreted turn state above. - // Packet cadence is carried separately by ShouldSendMovementEvent, so - // local mouse sampling cannot flood MoveToState. - if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) - { - Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver - * _motion.InterpretedState.TurnSpeed * dt; - } - // Wrap yaw to [-PI, PI] so it doesn't grow unbounded. - while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; - while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI; - - // Sync the body's orientation quaternion with our Yaw (rotation about Z). - // Convention: Yaw=0 faces +X. Local body +Y is "forward", so we rotate - // by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0). - _body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f); - - // ── 2. Install grounded movement source ─────────────────────────────── - // Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the - // PartArray into one local Frame. Ordinary grounded movement is that - // animation-authored displacement, not get_state_velocity(). The - // latter remains the explicit fallback for headless/test controllers - // that have no attached animation runtime and for jump launch below. - if (_body.OnWalkable) - { - float savedWorldVz = _body.Velocity.Z; - if (hasAnimationRootMotion) - { - // Do not let m_velocityVector move the body a second time. - // The pending animation delta is composed with the - // PositionManager below and swept through the normal collision - // path in the same physics quantum. - _body.Velocity = new Vector3(0f, 0f, savedWorldVz); - } - else - { - var stateVel = _motion.get_state_velocity(); - // Preserve LastMoveWasAutonomous: retail stores it at motion - // event boundaries, never at this per-tick fallback write. - _body.set_local_velocity( - new Vector3(stateVel.X, stateVel.Y, savedWorldVz), - autonomous: _body.LastMoveWasAutonomous); - } - } - - // ── 3. Jump (charged) ───────────────────────────────────────────────── + // ── 1. Jump input (charged) ─────────────────────────────────────────── // Hold spacebar to charge (0→1 over JumpChargeRate seconds). // Release to execute: jump(extent) validates + sets JumpExtent, // then LeaveGround() applies the scaled velocity via GetLeaveGroundVelocity. @@ -1491,7 +1538,7 @@ public sealed class PlayerMovementController if (!_jumpCharging) { _jumpCharging = true; - _jumpExtent = 0f; + _jumpExtent = 0f; // R3-W6 (map R1): retail's charge_jump fires at charge // START (SmartBox/input boundary 0x0056afac) — the ONLY // place StandingLongJump arms (grounded + Ready + no @@ -1537,234 +1584,252 @@ public sealed class PlayerMovementController _body.set_local_velocity(outJumpVelocity.Value, autonomous: true); } _jumpCharging = false; - _jumpExtent = 0f; + _jumpExtent = 0f; } - // ── 4. Integrate physics (gravity, friction, sub-stepping) ──────────── - // - // L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj:: - // update_object skips integration when accumulated dt is below - // MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz - // render. We accumulate per-frame dt and only integrate (with the - // accumulated dt) when the threshold is reached. See _physicsAccum - // declaration for the full retail trace evidence. - var preIntegratePos = _body.Position; - bool physicsTickRan = false; - float lastTickDt = 0f; // the dt actually integrated this frame (for cached_velocity) - Vector3 oldTickEndPos = _currPhysicsPos; - _physicsAccum += dt; - - if (_physicsAccum > PhysicsBody.HugeQuantum) + // ── 2. Run admitted complete-object quanta ──────────────────────────── + // CPhysicsObj::update_object (0x00515D10) retains a remainder at or + // below MinQuantum, splits elapsed time above MaxQuantum, and discards + // stale gaps above HugeQuantum. Every admitted quantum executes the + // whole object update below; animation never runs on a render-only + // fragment. + bool reactivated = _objectClock.Activate(); + _body.TransientState |= TransientStateFlags.Active; + RetailObjectQuantumBatch quantumBatch = reactivated + ? default + : _objectClock.Advance(dt); + AdvancedObjectQuantumLastTick = quantumBatch.Count > 0; + bool justLanded = false; + if (quantumBatch.Discarded) { - // Stale frame (debugger break, GC pause). Discard accumulated dt. - _physicsAccum = 0f; - _pendingAnimationRootMotion = Vector3.Zero; _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; } - else if (_physicsAccum >= PhysicsBody.MinQuantum) + + for (int qi = 0; qi < quantumBatch.Count; qi++) { - // Integrate accumulated dt, clamped to MaxQuantum so a long - // pause doesn't produce one giant integration step. - float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum); - lastTickDt = tickDt; + float tickDt = quantumBatch.GetQuantum(qi); - // R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) — - // PositionManager::adjust_offset (@0x00512d0e) composes the - // sticky steer into this quantum's motion BEFORE - // UpdatePhysicsInternal, so the transition sweep below resolves - // it like any other movement (preIntegratePos was captured - // above). The delta's Origin is mover-LOCAL (rotate out by the - // body orientation); the heading is RELATIVE and writes Yaw — - // the authoritative facing the body quaternion is re-derived - // from every frame (a quaternion-only write would be clobbered). - // Seed the SAME frame that PositionManager receives. Retail's - // Sticky interpolation deliberately replaces PartArray root - // displacement rather than adding a second movement source. - var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); - if (hasAnimationRootMotion && _body.OnWalkable) - pmDelta.Origin = _pendingAnimationRootMotion; - _pendingAnimationRootMotion = Vector3.Zero; + // CPhysicsObj::UpdatePositionInternal (0x00512C30): visible objects + // advance their PartArray first. The complete Frame survives; only its + // origin is scaled while OnWalkable or zeroed while airborne. + var pmDelta = _positionManagerDeltaScratch; + pmDelta.Reset(); + if (_advanceAnimationRootMotion is { } advanceRootMotion) + { + _animationRootMotionScratch.Reset(); + advanceRootMotion(tickDt, _animationRootMotionScratch); + pmDelta.Origin = _body.OnWalkable + ? _animationRootMotionScratch.Origin * ObjectScale + : Vector3.Zero; + pmDelta.Orientation = _animationRootMotionScratch.Orientation; + } + else if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) + { + // AP-77: explicit no-PartArray/headless fallback. Production + // humanoids consume the DAT-authored complete CSequence Frame. + Yaw -= 1.5f + * _motion.InterpretedState.TurnSpeed + * tickDt; + } + if (_body.OnWalkable) + { + float savedWorldVz = _body.Velocity.Z; + if (hasAnimationRootMotion) + { + _body.Velocity = new Vector3(0f, 0f, savedWorldVz); + } + else + { + Vector3 stateVelocity = _motion.get_state_velocity(); + _body.set_local_velocity( + new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz), + autonomous: _body.LastMoveWasAutonomous); + } + } + + var preIntegratePos = _body.Position; + Vector3 oldTickEndPos = _currPhysicsPos; + + // PositionManager::adjust_offset (0x00512D0E) mutates the SAME + // complete Frame after PartArray. Interpolation may replace it; + // Sticky/Constraint then compose according to their retail rules. PositionManager?.AdjustOffset(pmDelta, tickDt); if (pmDelta.Origin != Vector3.Zero) _body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation); if (!pmDelta.Orientation.IsIdentity) - Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading( - AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw) - + pmDelta.GetHeading()); + { + _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( + _body.Position, + _body.Orientation, + _body.Orientation * pmDelta.Orientation); + } _body.calc_acceleration(); _body.UpdatePhysicsInternal(tickDt); - _physicsAccum -= tickDt; - physicsTickRan = true; - } - // Else: dt below MinQuantum threshold — skip integration. Position - // and velocity remain unchanged; Resolve below runs as a zero-distance - // sphere sweep (no collision possible) and the rest of the frame - // (motion commands, animation, return) runs normally. - var postIntegratePos = _body.Position; - // retail UpdateObjectInternal (pc:283657): the transition + handle_all_collisions are - // reached ONLY when the integrated candidate actually MOVED off m_position. This gate - // is load-bearing for the #182 bleed: after fsf>1 zeros a blocked jump's velocity, the - // next frame integrates zero motion (velMag2==0 → no position step, just v += gravity), - // so the candidate hasn't moved yet — handle_all_collisions MUST be skipped that frame - // or it re-zeros the gravity velocity and the body re-wedges instead of falling off. - bool candidateMoved = postIntegratePos != preIntegratePos; + // Retail process_hooks is the final UpdatePositionInternal step: + // after physics, before the transition and manager tail. + _processAnimationHooks?.Invoke(); + var postIntegratePos = _body.Position; - // ── 5. Collision resolution via CTransition sphere-sweep ───────────── - // The Transition system subdivides the movement from pre→post into - // sphere-radius steps, testing terrain collision at each step. - // Falls back to simple Z-snap if transition fails. - var resolveResult = _physics.ResolveWithTransition( - preIntegratePos, postIntegratePos, CellId, - sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480) - // #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP - // (InitPath places the head sphere center at height − radius). The - // dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350 - // (top 1.830) and Height = 1.835 — retail collides with that - // sphere list verbatim (CPhysicsObj::transition 0x00512dc0 → - // init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put - // the head sphere center at 0.72 — the top 0.63 m of the - // character had NO collision, letting the player climb into a - // 1.3 m window alcove head-through-lintel. Register TS-46. - sphereHeight: 1.835f, - stepUpHeight: StepUpHeight, - stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight - isOnGround: _body.OnWalkable, - body: _body, // persist ContactPlane across frames for slope tracking - // L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes - // EdgeSlide, and PhysicsObj.get_object_info copies that bit into - // OBJECTINFO. Keep it explicit here so edge/cliff handling runs - // under the same flag profile as retail player movement. - // - // Commit C 2026-04-29 — local player is always IsPlayer. - // The PK/PKLite/Impenetrable bits come from PlayerDescription's - // PlayerKillerStatus property; not yet parsed (non-PK pair → walks - // through other non-PK players, which is retail's default for - // ACE's character creation defaults too). - moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer - | AcDream.Core.Physics.ObjectInfoState.EdgeSlide, - // Fix #42: skip self in FindObjCollisions. Wired by GameWindow - // when the local player entity spawns (or stays 0 in tests, in - // which case there's no registered ShadowEntry to collide with - // anyway). - movingEntityId: LocalEntityId); + // retail UpdateObjectInternal (pc:283657): the transition + handle_all_collisions are + // reached ONLY when the integrated candidate actually MOVED off m_position. This gate + // is load-bearing for the #182 bleed: after fsf>1 zeros a blocked jump's velocity, the + // next frame integrates zero motion (velMag2==0 → no position step, just v += gravity), + // so the candidate hasn't moved yet — handle_all_collisions MUST be skipped that frame + // or it re-zeros the gravity velocity and the body re-wedges instead of falling off. + bool candidateMoved = postIntegratePos != preIntegratePos; - // L.4-diag (2026-04-30): trace position transitions so we can see - // whether the body is actually moving frame-to-frame on the steep - // roof, or whether it's frozen at the impact point. - if (AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled - && resolveResult.CollisionNormalValid) - { - Console.WriteLine( - $"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " + - $"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " + - $"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " + - $"isOnGround={resolveResult.IsOnGround}"); - } + // ── 3. Collision resolution via CTransition sphere-sweep ───────────── + // The Transition system subdivides the movement from pre→post into + // sphere-radius steps, testing terrain collision at each step. + // Falls back to simple Z-snap if transition fails. + var resolveResult = _physics.ResolveWithTransition( + preIntegratePos, postIntegratePos, CellId, + sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480) + // #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP + // (InitPath places the head sphere center at height − radius). The + // dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350 + // (top 1.830) and Height = 1.835 — retail collides with that + // sphere list verbatim (CPhysicsObj::transition 0x00512dc0 → + // init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put + // the head sphere center at 0.72 — the top 0.63 m of the + // character had NO collision, letting the player climb into a + // 1.3 m window alcove head-through-lintel. Register TS-46. + sphereHeight: 1.835f, + stepUpHeight: StepUpHeight, + stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight + isOnGround: _body.OnWalkable, + body: _body, // persist ContactPlane across frames for slope tracking + // L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes + // EdgeSlide, and PhysicsObj.get_object_info copies that bit into + // OBJECTINFO. Keep it explicit here so edge/cliff handling runs + // under the same flag profile as retail player movement. + // + // Commit C 2026-04-29 — local player is always IsPlayer. + // The PK/PKLite/Impenetrable bits come from PlayerDescription's + // PlayerKillerStatus property; not yet parsed (non-PK pair → walks + // through other non-PK players, which is retail's default for + // ACE's character creation defaults too). + moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer + | AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + // Fix #42: skip self in FindObjCollisions. Wired by GameWindow + // when the local player entity spawns (or stays 0 in tests, in + // which case there's no registered ShadowEntry to collide with + // anyway). + movingEntityId: LocalEntityId); - // ── Apply the resolve: retail UpdateObjectInternal (0x005156b0) → - // SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780). - // #182 verbatim rebuild: this REPLACES the ad-hoc airborne-only bounce with the - // frames_stationary_fall-driven velocity model — the airborne-stuck bleed - // (fsf>1 → velocity zeroed) that lets a blocked jump fall/glide off a monster crowd. + // L.4-diag (2026-04-30): trace position transitions so we can see + // whether the body is actually moving frame-to-frame on the steep + // roof, or whether it's frozen at the impact point. + if (AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled + && resolveResult.CollisionNormalValid) + { + Console.WriteLine( + $"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " + + $"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " + + $"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " + + $"isOnGround={resolveResult.IsOnGround}"); + } - // cached_velocity = realized displacement / dt (retail's SEPARATE reporting/DR value, - // pc:005158cb-5158ff; not fed back into the integrator velocity). - _body.CachedVelocity = (candidateMoved && physicsTickRan && lastTickDt > 0f) - ? (resolveResult.Position - preIntegratePos) / lastTickDt - : Vector3.Zero; + // ── Apply the resolve: retail UpdateObjectInternal (0x005156b0) → + // SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780). + // #182 verbatim rebuild: this REPLACES the ad-hoc airborne-only bounce with the + // frames_stationary_fall-driven velocity model — the airborne-stuck bleed + // (fsf>1 → velocity zeroed) that lets a blocked jump fall/glide off a monster crowd. - // Capture prev contact/walkable BEFORE committing the new state — retail - // SetPositionInternal reads transient_state at entry for handle_all_collisions' args. - bool prevContact = _body.InContact; - bool prevOnWalkable = _body.OnWalkable; + // cached_velocity = realized displacement / dt (retail's SEPARATE reporting/DR value, + // pc:005158cb-5158ff; not fed back into the integrator velocity). + _body.CachedVelocity = candidateMoved + ? (resolveResult.Position - preIntegratePos) / tickDt + : Vector3.Zero; - _body.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position); - if (physicsTickRan) - { + // Capture prev contact/walkable BEFORE committing the new state — retail + // SetPositionInternal reads transient_state at entry for handle_all_collisions' args. + bool prevContact = _body.InContact; + bool prevOnWalkable = _body.OnWalkable; + + _body.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position); _prevPhysicsPos = oldTickEndPos; _currPhysicsPos = _body.Position; - } - // SetPositionInternal contact determination (pc:283468-510). acdream's resolver - // reports IsOnGround even during an UPWARD jump (it always step-downs), so the - // contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a - // jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the - // landing state is committed before any reflect — this ordering plus the ungated - // small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral - // (the old code reflected FIRST, so the reflected +Z defeated the landing gate). - bool justLanded = false; - if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f) - { - bool wasAirborne = !_body.OnWalkable; - _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - _body.calc_acceleration(); - - // Stop the fall on landing (retail settles the into-ground component via - // calc_friction next frame; the hand-zero avoids a one-frame floor dip and makes - // handle_all_collisions' landing reflect a no-op — dot(v,n)=0). - if (_body.Velocity.Z < 0f) - _body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); - - if (wasAirborne) + // SetPositionInternal contact determination (pc:283468-510). acdream's resolver + // reports IsOnGround even during an UPWARD jump (it always step-downs), so the + // contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a + // jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the + // landing state is committed before any reflect — this ordering plus the ungated + // small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral + // (the old code reflected FIRST, so the reflected +Z defeated the landing gate). + bool landedThisQuantum = false; + if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f) { - // R4-V5 → R5-V5: retail order — minterp then moveto - // (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the - // airborne UseTime contact gate. LeaveGround has NO moveto side (§2e). - Movement.HitGround(); - justLanded = true; + bool wasAirborne = !_body.OnWalkable; + _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + _body.calc_acceleration(); + + // Stop the fall on landing (retail settles the into-ground component via + // calc_friction next frame; the hand-zero avoids a one-frame floor dip and makes + // handle_all_collisions' landing reflect a no-op — dot(v,n)=0). + if (_body.Velocity.Z < 0f) + _body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); + + if (wasAirborne) + { + // R4-V5 → R5-V5: retail order — minterp then moveto + // (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the + // airborne UseTime contact gate. LeaveGround has NO moveto side (§2e). + Movement.HitGround(); + landedThisQuantum = true; + } } - } - else - { - // Airborne: jumping up (IsOnGround but v.z>0) OR no ground found. - _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); - _body.calc_acceleration(); + else + { + // Airborne: jumping up (IsOnGround but v.z>0) OR no ground found. + _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); + _body.calc_acceleration(); + } + + // handle_all_collisions (0x00514780): reflect the into-surface velocity (fsf≤1) or + // ZERO it entirely (fsf>1 — THE airborne-stuck fix). The Stationary* bit round-trip is + // owned by the Core resolve writeback. Restores retail's should_reflect rule; on a + // landing the Velocity.Z hand-zero above makes the reflect a no-op (no micro-bounce). + // Gated on candidateMoved (retail SetPositionInternal is only reached when the candidate + // moved) so a no-move frame doesn't re-zero the gravity velocity rebuilding after a bleed. + if (candidateMoved) + { + PhysicsObjUpdate.HandleAllCollisions( + _body, + resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, + prevContact, prevOnWalkable, nowOnWalkable: _body.OnWalkable); + } + + // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's LeaveGround (0x00528b00) — + // jump launches and walk-off-a-ledge both route here. The landing branch above fires + // HitGround on the opposite edge. + if (!_body.OnWalkable && !_wasAirborneLastFrame) + _motion.LeaveGround(); + + _wasAirborneLastFrame = !_body.OnWalkable; + justLanded |= landedThisQuantum; + UpdateCellId(resolveResult.CellId, "resolver"); + + // Named retail CPhysicsObj::UpdateObjectInternal (0x005156B0): + // process_hooks has already completed inside UpdatePositionInternal, + // then the transition commits, and only then does the ordered manager + // tail runs in its exact Detection/Target/Movement/PartArray/ + // Position order. DetectionManager is not ported, so its slot is + // presently null. + RetailObjectManagerTail.Run( + handleTargeting, + Movement, + _motion.CheckForCompletedMotions, + PositionManager); + } - // handle_all_collisions (0x00514780): reflect the into-surface velocity (fsf≤1) or - // ZERO it entirely (fsf>1 — THE airborne-stuck fix). The Stationary* bit round-trip is - // owned by the Core resolve writeback. Restores retail's should_reflect rule; on a - // landing the Velocity.Z hand-zero above makes the reflect a no-op (no micro-bounce). - // Gated on candidateMoved (retail SetPositionInternal is only reached when the candidate - // moved) so a no-move frame doesn't re-zero the gravity velocity rebuilding after a bleed. - if (candidateMoved) - { - PhysicsObjUpdate.HandleAllCollisions( - _body, - resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, - prevContact, prevOnWalkable, nowOnWalkable: _body.OnWalkable); - } - - // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's LeaveGround (0x00528b00) — - // jump launches and walk-off-a-ledge both route here. The landing branch above fires - // HitGround on the opposite edge. - if (!_body.OnWalkable && !_wasAirborneLastFrame) - _motion.LeaveGround(); - - _wasAirborneLastFrame = !_body.OnWalkable; - UpdateCellId(resolveResult.CellId, "resolver"); - - // Named retail CPhysicsObj::UpdateObjectInternal (0x005156B0): - // process_hooks has already completed inside UpdatePositionInternal, - // then the transition commits, and only then does the ordered manager - // tail run. DetectionManager is not ported yet, so its slot is absent. - // PositionManager's clock stays tied to a consumed physics quantum; - // the remaining per-render versus per-quantum unification belongs to - // the R6 object scheduler rather than this ordering cutover. - RetailObjectManagerTail.Run( - checkDetection: null, - handleTargeting, - movementUseTime: Movement.UseTime, - partArrayHandleMovement: _motion.CheckForCompletedMotions, - positionUseTime: physicsTickRan && PositionManager is { } positionManager - ? positionManager.UseTime - : null); - - // ── 6. Determine outbound motion commands ───────────────────────────── + // ── 4. Determine outbound motion commands ───────────────────────────── uint? outForwardCmd = null; float? outForwardSpeed = null; uint? outSidestepCmd = null; @@ -1792,13 +1857,13 @@ public sealed class PlayerMovementController // the W6 map's §2b TODO flags, and R7 owns outbound anyway. if (input.Forward) { - outForwardCmd = MotionCommand.WalkForward; + outForwardCmd = MotionCommand.WalkForward; outForwardSpeed = 1.0f; // RAW — ACE recomputes the broadcast speed } else if (input.Backward) { - outForwardCmd = MotionCommand.WalkBackward; - outForwardSpeed = 1.0f; + outForwardCmd = MotionCommand.WalkBackward; + outForwardSpeed = 1.0f; } // Source the outbound axis from the canonical input-owned raw channel. @@ -1837,11 +1902,11 @@ public sealed class PlayerMovementController // frame) is OR-ed in — by construction an edge IS a state change // (retail dispatches only on edges), keeping the wire cadence // identical to the old output-comparison. - bool changed = outForwardCmd != _prevForwardCmd - || outSidestepCmd != _prevSidestepCmd - || outTurnCmd != _prevTurnCmd + bool changed = outForwardCmd != _prevForwardCmd + || outSidestepCmd != _prevSidestepCmd + || outTurnCmd != _prevTurnCmd || !FloatsEqual(outForwardSpeed, _prevForwardSpeed) - || runHold != _prevRunHold + || runHold != _prevRunHold || motionEdgeFired; bool mouseMovementEventDue = _mouseMovementEventPending @@ -1852,11 +1917,11 @@ public sealed class PlayerMovementController _mouseMovementEventPending = true; bool shouldSendMovementEvent = movementEventRequested || mouseMovementEventDue; - _prevForwardCmd = outForwardCmd; - _prevSidestepCmd = outSidestepCmd; - _prevTurnCmd = outTurnCmd; - _prevForwardSpeed = outForwardSpeed; - _prevRunHold = runHold; + _prevForwardCmd = outForwardCmd; + _prevSidestepCmd = outSidestepCmd; + _prevTurnCmd = outTurnCmd; + _prevForwardSpeed = outForwardSpeed; + _prevRunHold = runHold; static bool FloatsEqual(float? a, float? b) { diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs index d6077509..67be00bc 100644 --- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs +++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs @@ -21,18 +21,33 @@ public sealed class RetailLocalPlayerFrameController private readonly Action _project; private readonly Action _sendPreNetwork; private readonly Action _sendPostNetwork; + private readonly Func + _objectClockDisposition; private AdvancedFrame? _advancedFrame; private readonly record struct AdvancedFrame( PlayerMovementController Controller, - MovementResult Movement); + MovementResult Movement, + bool ObjectAdvanced, + bool Hidden, + bool ObjectQuantumAdvanced); public readonly record struct PresentationFrame( MovementResult Movement, bool Hidden, bool AdvancedBeforeNetwork); + /// + /// Whether the pre-network Hidden player update admitted a complete object + /// quantum whose retained CPartArray pose must be sampled this frame. + /// + internal bool HiddenPartPoseDirty => _advancedFrame is + { + Hidden: true, + ObjectQuantumAdvanced: true, + }; + public RetailLocalPlayerFrameController( Func canPresentPlayer, Func getController, @@ -42,7 +57,9 @@ public sealed class RetailLocalPlayerFrameController Func isHidden, Action project, Action sendPreNetwork, - Action sendPostNetwork) + Action sendPostNetwork, + Func? + objectClockDisposition = null) { _canPresentPlayer = canPresentPlayer ?? throw new ArgumentNullException(nameof(canPresentPlayer)); @@ -62,6 +79,8 @@ public sealed class RetailLocalPlayerFrameController ?? throw new ArgumentNullException(nameof(sendPreNetwork)); _sendPostNetwork = sendPostNetwork ?? throw new ArgumentNullException(nameof(sendPostNetwork)); + _objectClockDisposition = objectClockDisposition + ?? (() => AcDream.Core.Physics.RetailObjectClockDisposition.Advance); } /// @@ -75,16 +94,54 @@ public sealed class RetailLocalPlayerFrameController if (!_canPresentPlayer() || controller is null) return; + if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f) + { + bool rejectedHidden = _isHidden(); + MovementResult rejected = controller.CapturePresentationResult(); + _project(controller, rejected, rejectedHidden); + _advancedFrame = new AdvancedFrame( + controller, + rejected, + ObjectAdvanced: false, + Hidden: rejectedHidden, + ObjectQuantumAdvanced: false); + return; + } + controller.LocalEntityId = _resolveLocalEntityId(); bool hidden = _isHidden(); + if (_objectClockDisposition() + is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend) + { + // CPhysicsObj::update_object returns before advancing time for a + // Frozen or cell-less object. Keep the retained projection live, + // but emit neither input actions nor AutonomousPosition from a + // physics tick that retail did not run. + controller.SuspendObjectUpdate(deltaSeconds); + MovementResult paused = controller.CapturePresentationResult(); + _project(controller, paused, hidden); + _advancedFrame = new AdvancedFrame( + controller, + paused, + ObjectAdvanced: false, + Hidden: hidden, + ObjectQuantumAdvanced: false); + return; + } + MovementResult movement = hidden ? controller.TickHidden(deltaSeconds, _handleTargeting) : controller.Update(deltaSeconds, _captureInput(), _handleTargeting); _project(controller, movement, hidden); _sendPreNetwork(controller, movement, hidden); - _advancedFrame = new AdvancedFrame(controller, movement); + _advancedFrame = new AdvancedFrame( + controller, + movement, + ObjectAdvanced: true, + Hidden: hidden, + ObjectQuantumAdvanced: controller.AdvancedObjectQuantumLastTick); } /// @@ -106,7 +163,15 @@ public sealed class RetailLocalPlayerFrameController advanced.Movement, controller); _project(controller, movement, hidden); - _advancedFrame = new AdvancedFrame(controller, movement); + _advancedFrame = new AdvancedFrame( + controller, + movement, + advanced.ObjectAdvanced, + advanced.Hidden, + advanced.ObjectQuantumAdvanced); + + if (!advanced.ObjectAdvanced) + return; } _sendPostNetwork(controller, hidden); @@ -131,7 +196,10 @@ public sealed class RetailLocalPlayerFrameController MovementResult movement = RefreshSpatialFields( advanced.Movement, controller); - frame = new PresentationFrame(movement, hidden, AdvancedBeforeNetwork: true); + frame = new PresentationFrame( + movement, + hidden, + AdvancedBeforeNetwork: advanced.ObjectAdvanced); return true; } diff --git a/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs b/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs new file mode 100644 index 00000000..a4059500 --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs @@ -0,0 +1,226 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter.Types; + +namespace AcDream.App.Physics; + +/// +/// Owns the body-backed, manager-less branch of retail +/// CPhysicsObj::UpdateObjectInternal (0x005156B0). A retained canonical +/// body can temporarily outlive its RemoteMotion or projectile component; it +/// must still compose the complete PartArray Frame, integrate physics, sweep +/// through Transition, commit its cell, and move its collision shadow. +/// +internal sealed class LiveEntityOrdinaryPhysicsUpdater +{ + private readonly PhysicsEngine _physics; + private readonly Func + _getSetupCylinder; + + public LiveEntityOrdinaryPhysicsUpdater( + PhysicsEngine physics, + Func getSetupCylinder) + { + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _getSetupCylinder = getSetupCylinder + ?? throw new ArgumentNullException(nameof(getSetupCylinder)); + } + + public bool Tick( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + Frame rootFrame, + float objectScale, + float quantum, + int liveCenterX, + int liveCenterY, + ulong objectClockEpoch, + AnimationSequencer? sequencer, + Action captureAnimationHooks) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(rootFrame); + ArgumentNullException.ThrowIfNull(captureAnimationHooks); + if (record.PhysicsBody is not { } body + || !IsCurrent( + runtime, + record, + entity, + body, + objectClockEpoch)) + { + return false; + } + + body.State = record.FinalPhysicsState; + Vector3 priorPosition = body.Position; + Quaternion priorOrientation = body.Orientation; + bool previousContact = body.InContact; + bool previousOnWalkable = body.OnWalkable; + + // UpdatePositionInternal 0x00512CA1: PartArray root translation is + // scaled only while transient OnWalkable is set. Its orientation stays + // in the complete Frame and composes regardless of that translation + // gate. + Vector3 candidatePosition = priorPosition; + if (body.OnWalkable && rootFrame.Origin != Vector3.Zero) + { + candidatePosition += Vector3.Transform( + rootFrame.Origin * objectScale, + priorOrientation); + } + + Quaternion candidateOrientation = priorOrientation; + if (!rootFrame.Orientation.IsIdentity) + { + candidateOrientation = FrameOps.SetRotate( + candidatePosition, + priorOrientation, + priorOrientation * rootFrame.Orientation); + } + + body.SetFrameInCurrentCell(candidatePosition, candidateOrientation); + body.calc_acceleration(); + body.UpdatePhysicsInternal(quantum); + // Omega integration writes Orientation directly; mirror it into the + // retained Position frame before Transition consumes the candidate. + body.SetFrameInCurrentCell(body.Position, body.Orientation); + + // UpdatePositionInternal processes PartArray hooks after physics but + // before UpdateObjectInternal performs its transition sweep. + if (sequencer is not null) + captureAnimationHooks(entity.Id, sequencer); + if (!IsCurrent( + runtime, + record, + entity, + body, + objectClockEpoch)) + return false; + + Vector3 integratedPosition = body.Position; + uint sourceCellId = record.FullCellId; + uint resolvedCellId = sourceCellId; + bool frameChanged = integratedPosition != priorPosition + || body.Orientation != priorOrientation; + var (radius, height) = _getSetupCylinder(record.ServerGuid, entity); + + if (integratedPosition != priorPosition + && sourceCellId != 0 + && radius >= 0.05f + && _physics.LandblockCount > 0) + { + ResolveResult resolved = _physics.ResolveWithTransition( + priorPosition, + integratedPosition, + sourceCellId, + radius, + height, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: previousOnWalkable, + body: body, + moverFlags: IsPlayerGuid(record.ServerGuid) + ? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide + : ObjectInfoState.EdgeSlide, + movingEntityId: entity.Id); + + if (resolved.Ok) + { + resolvedCellId = resolved.CellId != 0 + ? resolved.CellId + : sourceCellId; + body.CommitTransitionPosition(resolvedCellId, resolved.Position); + PhysicsObjUpdate.CommitSetPositionTransition( + body, + resolved.InContact, + resolved.OnWalkable, + resolved.CollisionNormalValid, + resolved.CollisionNormal, + previousContact, + previousOnWalkable); + body.CachedVelocity = quantum > 0f + ? (body.Position - priorPosition) / quantum + : Vector3.Zero; + } + else + { + // transition() returned null: UpdateObjectInternal keeps the + // integrated frame in the current cell and reports no realized + // transition velocity. + body.CachedVelocity = Vector3.Zero; + } + } + else + { + // The no-PartArray-sphere arm calls set_frame and writes zero + // cached_velocity rather than fabricating a collision shape. + body.CachedVelocity = Vector3.Zero; + } + + if (!IsCurrent( + runtime, + record, + entity, + body, + objectClockEpoch)) + return false; + + entity.SetPosition(body.Position); + entity.Rotation = body.Orientation; + entity.ParentCellId = resolvedCellId; + if (resolvedCellId != sourceCellId + && !runtime.RebucketLiveEntity(record.ServerGuid, resolvedCellId)) + { + return false; + } + if (!IsCurrent( + runtime, + record, + entity, + body, + objectClockEpoch)) + return false; + + if (frameChanged && record.IsSpatiallyVisible && record.FullCellId != 0) + { + ShadowPositionSynchronizer.Sync( + _physics.ShadowObjects, + entity.Id, + body.Position, + body.Orientation, + record.FullCellId, + liveCenterX, + liveCenterY); + } + + return IsCurrent( + runtime, + record, + entity, + body, + objectClockEpoch); + } + + private static bool IsCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + PhysicsBody body, + ulong objectClockEpoch) => + runtime.IsCurrentSpatialRootObject(record) + && record.ObjectClockEpoch == objectClockEpoch + && ReferenceEquals(record.WorldEntity, entity) + && ReferenceEquals(record.PhysicsBody, body) + && record.RemoteMotionRuntime is null + && record.ProjectileRuntime is null; + + private static bool IsPlayerGuid(uint guid) => + (guid & 0xFF000000u) == 0x50000000u; +} diff --git a/src/AcDream.App/Physics/LiveEntityShadowPublisher.cs b/src/AcDream.App/Physics/LiveEntityShadowPublisher.cs new file mode 100644 index 00000000..6bd6cbfc --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityShadowPublisher.cs @@ -0,0 +1,56 @@ +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Physics; + +/// +/// Incarnation/residency gate for authoritative ordinary-remote collision +/// publication after a canonical rebucket callback. +/// +internal static class LiveEntityShadowPublisher +{ + internal static bool TryPublishRemote( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + ILiveEntityRemoteMotionRuntime remote, + ulong positionAuthorityVersion, + Action publish) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(remote); + ArgumentNullException.ThrowIfNull(publish); + + if (!CanPublish( + runtime, + record, + entity, + remote, + positionAuthorityVersion)) + { + return false; + } + + publish(); + return CanPublish( + runtime, + record, + entity, + remote, + positionAuthorityVersion); + } + + private static bool CanPublish( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + ILiveEntityRemoteMotionRuntime remote, + ulong positionAuthorityVersion) => + (record.FinalPhysicsState & PhysicsStateFlags.Hidden) == 0 + && ReferenceEquals(record.WorldEntity, entity) + && runtime.IsCurrentPositionAuthority(record, positionAuthorityVersion) + && runtime.IsCurrentSpatialRemoteMotion(record, remote); +} diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index cf1d2c2b..7e68faef 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -5,6 +5,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; +using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; namespace AcDream.App.Physics; @@ -30,26 +31,34 @@ internal sealed class ProjectileController internal Runtime( ushort generation, PhysicsBody body, - ProjectileCollisionSphere collisionSphere, - bool hasPartArray) + ProjectileCollisionSphere collisionSphere) { Generation = generation; Body = body; CollisionSphere = collisionSphere; - HasPartArray = hasPartArray; } internal ushort Generation { get; } public PhysicsBody Body { get; } internal ProjectileCollisionSphere CollisionSphere { get; } - internal bool HasPartArray { get; } + internal ulong PredictionAuthorityVersion { get; private set; } + internal void InvalidatePrediction() => + PredictionAuthorityVersion++; } + internal readonly record struct QuantumStep( + LiveEntityRecord Record, + Runtime Runtime, + WorldEntity Entity, + ProjectileQuantumPreparation Preparation, + ulong PredictionAuthorityVersion); + private readonly LiveEntityRuntime _liveEntities; private readonly ProjectilePhysicsStepper _stepper; private readonly ShadowObjectRegistry _shadows; private readonly Func _setupResolver; private readonly Action _publishRootPose; + private readonly Func<(int X, int Y)> _liveCenterProvider; private readonly List _spatialProjectileSnapshot = new(); private double _lastFiniteGameTime; @@ -57,7 +66,8 @@ internal sealed class ProjectileController LiveEntityRuntime liveEntities, PhysicsEngine physicsEngine, Func? setupResolver = null, - Action? publishRootPose = null) + Action? publishRootPose = null, + Func<(int X, int Y)>? liveCenterProvider = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); ArgumentNullException.ThrowIfNull(physicsEngine); @@ -65,6 +75,7 @@ internal sealed class ProjectileController _shadows = physicsEngine.ShadowObjects; _setupResolver = setupResolver ?? (_ => null); _publishRootPose = publishRootPose ?? (_ => { }); + _liveCenterProvider = liveCenterProvider ?? (() => (0, 0)); _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; } @@ -126,7 +137,9 @@ internal sealed class ProjectileController ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(setup); - if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0) + if (!_liveEntities.TryGetRecord(record.ServerGuid, out var liveRecord) + || !ReferenceEquals(liveRecord, record) + || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0) return false; if (record.ProjectileRuntime is Runtime retained) @@ -158,12 +171,13 @@ internal sealed class ProjectileController _lastFiniteGameTime = currentTime; PhysicsBody body; - if (record.RemoteMotionRuntime is { } remote) + if (record.PhysicsBody is { } sharedBody) { // Retail owns one CPhysicsObj. If a non-missile incarnation - // already created its MovementManager, classification adopts that - // same body instead of replacing it. - body = remote.Body; + // already created its MovementManager or entered the Physics-Static + // animation workset, classification adopts that same body instead + // of replacing it or replaying CreateObject vectors. + body = sharedBody; uint currentCellId = record.FullCellId; Vector3 currentCellLocal = CellLocalFromWorld( body.Position, @@ -219,38 +233,69 @@ internal sealed class ProjectileController $"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector."); return false; } - body = new PhysicsBody - { - State = record.FinalPhysicsState, - Friction = NormalizeFriction( - physics.Friction ?? record.Snapshot.Friction), - Elasticity = NormalizeElasticity( - physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f), - Orientation = entity.Rotation, - LastUpdateTime = currentTime, - }; - SetVelocity(body, velocity, currentTime); - body.Omega = omega; - body.SnapToCell( - wirePosition.LandblockId, - entity.Position, - new Vector3( - wirePosition.PositionX, - wirePosition.PositionY, - wirePosition.PositionZ)); + // Claim the incarnation's one CPhysicsObj before Rebucket can + // publish visibility callbacks. A callback that also needs a body + // must observe and adopt this same instance, never race a private + // projectile candidate into SetProjectileRuntime. + body = _liveEntities.GetOrCreatePhysicsBody( + record.ServerGuid, + _ => + { + var created = new PhysicsBody + { + State = record.FinalPhysicsState, + Friction = NormalizeFriction( + physics.Friction ?? record.Snapshot.Friction), + Elasticity = NormalizeElasticity( + physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f), + Orientation = entity.Rotation, + LastUpdateTime = currentTime, + }; + SetVelocity(created, velocity, currentTime); + created.Omega = omega; + created.SnapToCell( + wirePosition.LandblockId, + entity.Position, + new Vector3( + wirePosition.PositionX, + wirePosition.PositionY, + wirePosition.PositionZ)); + return created; + }); } uint canonicalCellId = body.CellPosition.ObjCellId; entity.SetPosition(body.Position); entity.Rotation = body.Orientation; entity.ParentCellId = canonicalCellId; - _liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId); + if (!_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId) + || !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord) + || !ReferenceEquals(currentRecord, record) + || !ReferenceEquals(currentRecord.WorldEntity, entity) + || !ReferenceEquals(currentRecord.PhysicsBody, body) + || (currentRecord.FinalPhysicsState & PhysicsStateFlags.Missile) == 0) + { + return false; + } + + // Rebucket publishes projection callbacks synchronously. A callback + // can re-enter classification for this same incarnation and install + // the one projectile component before this outer call resumes. Adopt + // that component; never replace it with a second Runtime that merely + // happens to share the canonical CPhysicsObj body. + if (currentRecord.ProjectileRuntime is Runtime concurrentRuntime) + return ReferenceEquals(concurrentRuntime.Body, body); + + // A newer Position can legally arrive from a projection callback + // while this independent State(Missile) classification is in flight. + // Both packets must win: install the component around the current + // canonical body frame, never the pre-callback cell captured above. + canonicalCellId = body.CellPosition.ObjCellId; var runtime = new Runtime( record.Generation, body, - sphere, - entity.MeshRefs.Count > 0); + sphere); _liveEntities.SetProjectileRuntime(record.ServerGuid, runtime); if (HasVisibleCell(record) && !IsHidden(record)) @@ -258,8 +303,8 @@ internal sealed class ProjectileController ShadowPositionSynchronizer.Sync( _shadows, entity.Id, - entity.Position, - entity.Rotation, + body.Position, + body.Orientation, canonicalCellId, liveCenterX, liveCenterY); @@ -282,13 +327,34 @@ internal sealed class ProjectileController } internal bool ApplyAuthoritativeVector( - uint serverGuid, + LiveEntityRecord expectedRecord, + Vector3 velocity, + Vector3 angularVelocity, + double currentTime) => + ApplyAuthoritativeVector( + expectedRecord, + expectedRecord.VectorAuthorityVersion, + expectedRecord.VelocityAuthorityVersion, + velocity, + angularVelocity, + currentTime); + + internal bool ApplyAuthoritativeVector( + LiveEntityRecord expectedRecord, + ulong expectedVectorAuthorityVersion, + ulong expectedVelocityAuthorityVersion, Vector3 velocity, Vector3 angularVelocity, double currentTime) { + ArgumentNullException.ThrowIfNull(expectedRecord); + uint serverGuid = expectedRecord.ServerGuid; if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)) return false; + if (!ReferenceEquals(record, expectedRecord) + || record.VectorAuthorityVersion != expectedVectorAuthorityVersion + || record.VelocityAuthorityVersion != expectedVelocityAuthorityVersion) + return false; // Once Missile is cleared, an existing MovementManager owns ordinary // jump/vector side effects (Airborne, gravity, LeaveGround) on this @@ -309,23 +375,48 @@ internal sealed class ProjectileController } _lastFiniteGameTime = currentTime; - SetVelocity(runtime.Body, velocity, currentTime); - runtime.Body.Omega = angularVelocity; - if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid) - && !IsHidden(record)) - Deactivate(runtime.Body); + runtime.InvalidatePrediction(); + if (!_liveEntities.TryCommitAuthoritativeVector( + record, + runtime.Body, + velocity, + angularVelocity, + currentTime)) + { + return true; + } return true; } internal bool ApplyAuthoritativeState( - uint serverGuid, + LiveEntityRecord expectedRecord, + PhysicsStateFlags state, + double currentTime, + int liveCenterX, + int liveCenterY) => + ApplyAuthoritativeState( + expectedRecord, + expectedRecord.StateAuthorityVersion, + state, + currentTime, + liveCenterX, + liveCenterY); + + internal bool ApplyAuthoritativeState( + LiveEntityRecord expectedRecord, + ulong expectedStateAuthorityVersion, PhysicsStateFlags state, double currentTime, int liveCenterX, int liveCenterY) { + ArgumentNullException.ThrowIfNull(expectedRecord); + uint serverGuid = expectedRecord.ServerGuid; if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)) return false; + if (!ReferenceEquals(record, expectedRecord) + || record.StateAuthorityVersion != expectedStateAuthorityVersion) + return false; bool validClock = double.IsFinite(currentTime); double effectiveClock = validClock @@ -339,6 +430,7 @@ internal sealed class ProjectileController if (record.ProjectileRuntime is Runtime runtime) { + runtime.InvalidatePrediction(); bool wasMissile = (runtime.Body.State & PhysicsStateFlags.Missile) != 0; runtime.Body.State = state; @@ -398,16 +490,46 @@ internal sealed class ProjectileController /// streaming origin changes never leak into the canonical cell frame. /// internal bool ApplyAuthoritativePosition( - uint serverGuid, + LiveEntityRecord expectedRecord, Vector3 worldPosition, Vector3 cellLocalPosition, Quaternion orientation, + Vector3 velocity, + uint fullCellId, + double currentTime, + int liveCenterX, + int liveCenterY) => + ApplyAuthoritativePosition( + expectedRecord, + expectedRecord.PositionAuthorityVersion, + expectedRecord.VelocityAuthorityVersion, + worldPosition, + cellLocalPosition, + orientation, + velocity, + fullCellId, + currentTime, + liveCenterX, + liveCenterY); + + internal bool ApplyAuthoritativePosition( + LiveEntityRecord expectedRecord, + ulong expectedPositionAuthorityVersion, + ulong expectedVelocityAuthorityVersion, + Vector3 worldPosition, + Vector3 cellLocalPosition, + Quaternion orientation, + Vector3 velocity, uint fullCellId, double currentTime, int liveCenterX, int liveCenterY) { + ArgumentNullException.ThrowIfNull(expectedRecord); + uint serverGuid = expectedRecord.ServerGuid; if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime) + || !ReferenceEquals(record, expectedRecord) + || record.PositionAuthorityVersion != expectedPositionAuthorityVersion || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0 || record.WorldEntity is not { } entity) return false; @@ -415,6 +537,7 @@ internal sealed class ProjectileController if (!double.IsFinite(currentTime) || !IsFinite(worldPosition) || !IsFinite(cellLocalPosition) + || !IsFinite(velocity) || !PositionFrameValidation.IsValid( fullCellId, cellLocalPosition, @@ -427,20 +550,53 @@ internal sealed class ProjectileController _lastFiniteGameTime = currentTime; PhysicsBody body = runtime.Body; + runtime.InvalidatePrediction(); bool wasInWorld = body.InWorld; body.Orientation = orientation; body.SnapToCell(fullCellId, worldPosition, cellLocalPosition); body.State = record.FinalPhysicsState; + // PositionPack::UnPack initializes an absent velocity to zero and + // MoveOrTeleport installs that exact vector through set_velocity. + // Apply it to this retained CPhysicsObj before any callback can + // displace the incarnation; a replacement record owns another body. + if (record.VelocityAuthorityVersion == expectedVelocityAuthorityVersion + && !_liveEntities.TryCommitAuthoritativeVelocity( + record, + body, + velocity, + currentTime)) + { + return true; + } uint canonicalCellId = body.CellPosition.ObjCellId; entity.SetPosition(worldPosition); entity.Rotation = orientation; entity.ParentCellId = canonicalCellId; - _liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId); + if (!_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId) + || !TryGetCurrent(serverGuid, out LiveEntityRecord afterRebucket, out Runtime afterRuntime) + || !ReferenceEquals(afterRebucket, record) + || !ReferenceEquals(afterRuntime, runtime) + || !ReferenceEquals(afterRebucket.WorldEntity, entity) + || afterRebucket.PositionAuthorityVersion + != expectedPositionAuthorityVersion) + { + // This packet was a projectile correction when admitted. A + // re-entrant observer displaced that incarnation; report handled + // so the generic remote path cannot mutate the replacement. + return true; + } if (HasVisibleCell(record) && !IsHidden(record)) { if (!wasInWorld) + { + // prepare_to_enter_world rebases the CPhysicsObj timestamp in + // the same operation that restores Active. The record clock is + // canonical, but keep this legacy body field synchronized for + // the absolute-time Core API and diagnostics. + body.LastUpdateTime = currentTime; Activate(body, currentTime); + } body.InWorld = true; ShadowPositionSynchronizer.Sync( _shadows, @@ -466,13 +622,16 @@ internal sealed class ProjectileController } /// - /// Movement packets use the ordinary MovementManager/animation funnel on - /// this same body. The generic remote translation tick consults this gate - /// so only the projectile integrator commits its root frame. + /// Selects the retained body's ordinary-physics owner. Missile + /// classification always uses the projectile transition path. If the + /// authoritative Missile bit later clears, that same body still owns + /// ordinary physics until a RemoteMotion runtime exists to take it over. + /// Component lifetime and classification are deliberately separate. /// internal bool HandlesMovement(uint serverGuid) => TryGetCurrent(serverGuid, out LiveEntityRecord record, out _) - && (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0; + && ((record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0 + || record.RemoteMotionRuntime is null); internal bool TryGetBody(uint serverGuid, out PhysicsBody body) { @@ -501,6 +660,22 @@ internal sealed class ProjectileController int liveCenterX, int liveCenterY, Vector3? playerWorldPosition) + { + double elapsed = currentTime - _lastFiniteGameTime; + Tick( + currentTime, + double.IsFinite(elapsed) && elapsed > 0.0 ? (float)elapsed : 0f, + liveCenterX, + liveCenterY, + playerWorldPosition); + } + + internal void Tick( + double currentTime, + float elapsedSeconds, + int liveCenterX, + int liveCenterY, + Vector3? playerWorldPosition) { if (!double.IsFinite(currentTime)) { @@ -519,22 +694,50 @@ internal sealed class ProjectileController continue; runtime.Body.State = record.FinalPhysicsState; + if (!HasVisibleCell(record)) + { + SuspendOutsideWorld(runtime, entity.Id); + continue; + } + + if (!runtime.Body.InWorld) + { + runtime.Body.LastUpdateTime = currentTime; + runtime.Body.InWorld = true; + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + runtime.Body.Position, + runtime.Body.Orientation, + record.FullCellId, + liveCenterX, + liveCenterY); + _publishRootPose(entity); + } + if (IsHidden(record)) { - if (!HasVisibleCell(record)) + _shadows.Suspend(entity.Id); + // A retained animation or PositionManager owner consumes the + // same record clock in LiveEntityAnimationScheduler. A bare + // projectile has no such owner, but Hidden still consumes its + // ordinary update_time without integrating root physics. + if (record.AnimationRuntime is null + && record.RemoteMotionRuntime is null) { - SuspendOutsideWorld(runtime, entity.Id); - } - else - { - // UpdatePositionInternal skips root physics/PartArray - // while Hidden, but update_object still advances its - // clock and does not clear Active. - runtime.Body.InWorld = true; - runtime.Body.LastUpdateTime = currentTime; - if ((record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0) - Deactivate(runtime.Body); - _shadows.Suspend(entity.Id); + RetailObjectActivityResult hiddenActivity = + RetailObjectActivityGate.Evaluate( + record.ObjectClock, + runtime.Body, + _liveEntities.GetRootObjectClockDisposition(record.ServerGuid) + is RetailObjectClockDisposition.Advance, + record.HasPartArray, + (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0, + runtime.Body.Position, + playerWorldPosition, + elapsedSeconds); + if (hiddenActivity is RetailObjectActivityResult.Active) + record.ObjectClock.Advance(elapsedSeconds); } continue; } @@ -548,23 +751,6 @@ internal sealed class ProjectileController // hydrate as an ordinary live object. Restore its retained // shadow/body membership without reactivating projectile // integration or consuming the out-of-world clock gap. - if (!HasVisibleCell(record)) - { - SuspendOutsideWorld(runtime, entity.Id); - } - else if (!runtime.Body.InWorld) - { - runtime.Body.InWorld = true; - ShadowPositionSynchronizer.Sync( - _shadows, - entity.Id, - runtime.Body.Position, - runtime.Body.Orientation, - record.FullCellId, - liveCenterX, - liveCenterY); - _publishRootPose(entity); - } // A retained body without MovementManager still remains an // active CPhysicsObj after Missile is cleared. Continue the // same one-sphere update path; PhysicsEngine reads the final @@ -573,111 +759,185 @@ internal sealed class ProjectileController continue; } - if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)) - { - if (!HasVisibleCell(record)) - SuspendOutsideWorld(runtime, entity.Id); - else - Deactivate(runtime.Body); // Frozen retains its cell/shadow. + // Animated owners are admitted by LiveEntityAnimationScheduler so + // their PartArray, projectile physics, hooks, and manager tail all + // consume one shared object quantum in retail order. + if (record.AnimationRuntime is not null) continue; - } - if (!runtime.Body.InWorld) - { - runtime.Body.InWorld = true; - Activate(runtime.Body, currentTime); - ShadowPositionSynchronizer.Sync( - _shadows, - entity.Id, - runtime.Body.Position, - runtime.Body.Orientation, - record.FullCellId, - liveCenterX, - liveCenterY); - _publishRootPose(entity); - } - - // CPhysicsObj::update_object 0x00515D4D-0x00515DB2 keeps - // rendered objects active only within 96 world units of the - // player. Objects with no PartArray are exempt. - if (runtime.HasPartArray && playerWorldPosition is { } playerPosition) - { - float playerDistance = Vector3.Distance(runtime.Body.Position, playerPosition); - if (!float.IsFinite(playerDistance) || playerDistance > 96f) - { - Deactivate(runtime.Body); - continue; - } - Activate(runtime.Body, currentTime); - } - - bool isParented = record.Snapshot.ParentGuid is not null - || record.Snapshot.Physics?.Parent is not null; - ProjectileAdvanceResult result = _stepper.Advance( + RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate( + record.ObjectClock, runtime.Body, - currentTime, - record.FullCellId, - runtime.CollisionSphere, - entity.Id, - designatedTargetId: 0u, - isParented); - - // An owner can be deleted/replaced by a callback reached from a - // future collision sink. Never commit a completed step into a new - // incarnation that reused the same server GUID. - if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime currentRuntime) - || !ReferenceEquals(runtime, currentRuntime) - || !ReferenceEquals(record, current) - || !result.Simulated) + _liveEntities.GetRootObjectClockDisposition(record.ServerGuid) + is RetailObjectClockDisposition.Advance, + record.HasPartArray, + (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0, + runtime.Body.Position, + playerWorldPosition, + elapsedSeconds); + if (activity is not RetailObjectActivityResult.Active) continue; - uint priorCellId = record.FullCellId; - uint resolvedCellId = result.CellId != 0 - ? result.CellId - : priorCellId; - - entity.SetPosition(runtime.Body.Position); - entity.Rotation = runtime.Body.Orientation; - entity.ParentCellId = resolvedCellId; - if (resolvedCellId != priorCellId) - _liveEntities.RebucketLiveEntity(record.ServerGuid, resolvedCellId); - runtime.Body.SnapToCell( - resolvedCellId, - runtime.Body.Position, - CellLocalFromWorld( - runtime.Body.Position, - resolvedCellId, - liveCenterX, - liveCenterY)); - - // A successful transition can cross into an unloaded landblock. - // Rebucket first, then make the physics/shadow decision from the - // resulting projection state in this same update quantum. Retail - // never leaves a CPhysicsObj active in a cell that is outside the - // current object-maintenance world. - if (HasVisibleCell(record)) + RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds); + for (int qi = 0; qi < batch.Count; qi++) { - ShadowPositionSynchronizer.Sync( - _shadows, - entity.Id, - runtime.Body.Position, - runtime.Body.Orientation, - record.FullCellId, - liveCenterX, - liveCenterY); - _publishRootPose(entity); - } - else - { - SuspendOutsideWorld(runtime, entity.Id); + if (!AdvanceQuantum( + record, + batch.GetQuantum(qi), + liveCenterX, + liveCenterY)) + break; + if (record.RemoteMotionRuntime is RemoteMotion remote) + { + RetailObjectManagerTail.Run( + remote.Host?.TargetManager, + remote.Movement, + partArray: null, + remote.Host?.PositionManager); + } } } } + /// + /// Runs the projectile slice of one object quantum already admitted by + /// . Animated projectiles call + /// this between PartArray advancement and hook/manager processing. + /// + internal bool AdvanceQuantum( + LiveEntityRecord record, + float quantum, + int liveCenterX, + int liveCenterY) + { + if (!TryBeginQuantum(record, quantum, out QuantumStep step)) + return false; + return CompleteQuantum(step, liveCenterX, liveCenterY); + } + + internal bool TryBeginQuantum( + LiveEntityRecord record, + float quantum, + out QuantumStep step) + { + ArgumentNullException.ThrowIfNull(record); + step = default; + if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime runtime) + || !ReferenceEquals(record, current) + || current.WorldEntity is not { } entity + || IsHidden(current) + || !HasVisibleCell(current)) + return false; + + runtime.Body.State = current.FinalPhysicsState; + bool isParented = current.Snapshot.ParentGuid is not null + || current.Snapshot.Physics?.Parent is not null; + ProjectileQuantumPreparation preparation = _stepper.BeginQuantum( + runtime.Body, + quantum, + current.FullCellId, + runtime.CollisionSphere, + isParented); + if (!preparation.Simulated) + return false; + step = new QuantumStep( + current, + runtime, + entity, + preparation, + runtime.PredictionAuthorityVersion); + return true; + } + + internal bool CompleteQuantum( + in QuantumStep step, + int liveCenterX, + int liveCenterY) + { + LiveEntityRecord current = step.Record; + Runtime runtime = step.Runtime; + WorldEntity entity = step.Entity; + if (!IsCurrentQuantumStep(step)) + return false; + + ProjectileAdvanceResult result = _stepper.CompleteQuantum( + runtime.Body, + step.Preparation, + runtime.CollisionSphere, + entity.Id, + designatedTargetId: 0u); + if (!result.Simulated || !IsCurrentQuantumStep(step)) + return false; + + uint priorCellId = current.FullCellId; + uint resolvedCellId = result.CellId != 0 ? result.CellId : priorCellId; + // Commit the retained CPhysicsObj's complete frame before Rebucket + // publishes a loaded-to-pending edge. Pending is still the same live + // object; its canonical body must already name the destination cell + // so later hydration cannot resurrect the source-cell frame. + runtime.Body.SnapToCell( + resolvedCellId, + runtime.Body.Position, + CellLocalFromWorld( + runtime.Body.Position, + resolvedCellId, + liveCenterX, + liveCenterY)); + entity.SetPosition(runtime.Body.Position); + entity.Rotation = runtime.Body.Orientation; + entity.ParentCellId = resolvedCellId; + if (resolvedCellId != priorCellId + && !_liveEntities.RebucketLiveEntity(current.ServerGuid, resolvedCellId)) + { + return false; + } + // A legitimate destination can now be pending and therefore absent + // from the spatial-projectile index. Revalidate identity/mutation, + // not visibility, after the canonical cell commit. + if (!IsCurrentQuantumIdentity(step)) + { + return false; + } + + if (HasVisibleCell(current)) + { + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + runtime.Body.Position, + runtime.Body.Orientation, + current.FullCellId, + liveCenterX, + liveCenterY); + _publishRootPose(entity); + if (!IsCurrentQuantumIdentity(step)) + return false; + } + else + { + SuspendOutsideWorld(runtime, entity.Id); + } + return IsCurrentQuantumIdentity(step); + } + + private bool IsCurrentQuantumStep(in QuantumStep step) => + IsCurrentQuantumIdentity(step) + && _liveEntities.IsCurrentSpatialProjectile(step.Record, step.Runtime); + + private bool IsCurrentQuantumIdentity(in QuantumStep step) => + TryGetCurrent( + step.Record.ServerGuid, + out LiveEntityRecord current, + out Runtime runtime) + && ReferenceEquals(current, step.Record) + && ReferenceEquals(runtime, step.Runtime) + && ReferenceEquals(current.WorldEntity, step.Entity) + && runtime.PredictionAuthorityVersion + == step.PredictionAuthorityVersion; + private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) { - if (visible - || record.ProjectileRuntime is not Runtime runtime + if (record.ProjectileRuntime is not Runtime runtime || record.WorldEntity is not { } entity || !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) || !ReferenceEquals(current, record) @@ -686,6 +946,39 @@ internal sealed class ProjectileController return; } + if (visible) + { + // prepare_to_enter_world belongs to the authoritative projection + // edge, not to a legacy frame scanner. LiveEntityRuntime has + // already rebased the canonical object clock and Active bit before + // publishing this notification; restore the remaining CPhysicsObj + // residency bit so the first admitted scheduler quantum can run. + runtime.Body.State = record.FinalPhysicsState; + runtime.Body.InWorld = true; + if (IsHidden(record)) + { + _shadows.Suspend(entity.Id); + return; + } + + // add_object_to_cell installs the object's collision shadow as + // part of prepare_to_enter_world. Do this on the residency edge, + // not lazily on the next simulated quantum: a zero-velocity or + // newly ordinary retained projectile may have no later projectile + // step from which to repair its shadow membership. + (int liveCenterX, int liveCenterY) = _liveCenterProvider(); + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + runtime.Body.Position, + runtime.Body.Orientation, + record.FullCellId, + liveCenterX, + liveCenterY); + _publishRootPose(entity); + return; + } + // Pending/cell-less records retain their logical projectile runtime, // but retail exit_world removes them from active physics and collision // immediately. They are absent from the frame workset after this edge, diff --git a/src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs b/src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs new file mode 100644 index 00000000..2e46d1f9 --- /dev/null +++ b/src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs @@ -0,0 +1,145 @@ +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; + +namespace AcDream.App.Physics; + +/// +/// Applies the remote-object half of retail +/// MovementManager::unpack_movement (0x00524440) after the +/// packet timestamp gate has accepted an UpdateMotion event. +/// +/// +/// The packet owner is deliberately independent of a render PartArray. A +/// normal animated object supplies a ; +/// an animation-less object supplies and +/// CMotionInterp retains the same state on its canonical physics body. +/// This keeps the interrupt/style/type-0/sticky ordering identical for both +/// object shapes instead of maintaining a second headless protocol funnel. +/// +internal sealed class RemoteInboundMotionDispatcher +{ + private readonly Func< + MovementManager, + uint, + WorldSession.EntityMotionUpdate, + bool> _routeServerMoveTo; + private readonly Action _stickToObject; + + public RemoteInboundMotionDispatcher( + Func + routeServerMoveTo, + Action stickToObject) + { + _routeServerMoveTo = routeServerMoveTo + ?? throw new ArgumentNullException(nameof(routeServerMoveTo)); + _stickToObject = stickToObject + ?? throw new ArgumentNullException(nameof(stickToObject)); + } + + public RemoteInboundMotionDispatchResult Apply( + WorldSession.EntityMotionUpdate update, + MovementManager movement, + IInterpretedMotionSink? animationSink, + IPhysicsObjHost? host, + uint cellId, + uint fallbackForwardClass, + Func? isCurrent = null) + { + ArgumentNullException.ThrowIfNull(movement); + bool Current() => isCurrent?.Invoke() ?? true; + + MotionInterpreter motion = movement.Minterp; + uint previousForward = motion.InterpretedState.ForwardCommand; + RemoteInboundMotionDispatchResult Superseded() => new( + RoutedMoveTo: false, + AppliedInterpretedState: false, + PreviousForwardCommand: previousForward, + CurrentForwardCommand: motion.InterpretedState.ForwardCommand, + Superseded: true); + if (!Current()) + return Superseded(); + + // MovementManager::unpack_movement @00524440: these two calls occur + // before the movement-type switch for every accepted packet. + motion.InterruptCurrentMovement?.Invoke(); + if (!Current()) + return Superseded(); + motion.UnstickFromObject?.Invoke(); + if (!Current()) + return Superseded(); + + uint wireStyle = update.MotionState.Stance != 0 + ? 0x80000000u | update.MotionState.Stance + : 0x8000003Du; + if (motion.InterpretedState.CurrentStyle != wireStyle) + { + motion.DoMotion( + wireStyle, + new MovementParameters()); + if (!Current()) + return Superseded(); + } + + // Cases 6/7/8/9 are owned by MoveToManager and return directly. + bool routedMoveTo = _routeServerMoveTo(movement, cellId, update); + if (!Current()) + return Superseded(); + if (routedMoveTo) + { + return new RemoteInboundMotionDispatchResult( + RoutedMoveTo: true, + AppliedInterpretedState: false, + PreviousForwardCommand: previousForward, + CurrentForwardCommand: motion.InterpretedState.ForwardCommand); + } + + // Retail's ten-way switch returns zero for every unrecognised type; + // only case 0 enters the wholesale interpreted-state funnel. + if (update.MotionState.MovementType != 0) + { + return new RemoteInboundMotionDispatchResult( + RoutedMoveTo: false, + AppliedInterpretedState: false, + PreviousForwardCommand: previousForward, + CurrentForwardCommand: motion.InterpretedState.ForwardCommand); + } + + InboundInterpretedState interpreted = + InboundInterpretedMotionFactory.Create( + update.MotionState, + fallbackForwardClass); + motion.MoveToInterpretedState(interpreted, animationSink); + if (!Current()) + return Superseded(); + + // Case-0 tail order @00524583-0052458E: stick after the wholesale + // state copy, then write standing_longjump unconditionally. + if (update.MotionState.StickyObjectGuid is { } stickyGuid + && stickyGuid != 0) + { + _stickToObject(host, stickyGuid); + if (!Current()) + return Superseded(); + } + motion.StandingLongJump = update.MotionState.StandingLongJump; + + return new RemoteInboundMotionDispatchResult( + RoutedMoveTo: false, + AppliedInterpretedState: true, + PreviousForwardCommand: previousForward, + CurrentForwardCommand: interpreted.ForwardCommand); + } +} + +internal readonly record struct RemoteInboundMotionDispatchResult( + bool RoutedMoveTo, + bool AppliedInterpretedState, + uint PreviousForwardCommand, + uint CurrentForwardCommand, + bool Superseded = false) +{ + public bool ForwardCommandChanged => + AppliedInterpretedState + && PreviousForwardCommand != CurrentForwardCommand; +} diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index cb594153..4a4078e0 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -21,9 +21,7 @@ namespace AcDream.App.Physics; /// ResolveWithTransition sweep + shadow-follows-resolved, so packed PLAYER /// remotes de-overlap exactly like NPCs (retail UpdateObjectInternal /// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is -/// the omega handling (players keep the ObservedOmega||seqOmega world-frame -/// fallback; NPCs + airborne bodies use ObservedOmega-only body-frame) and -/// the !IsPlayerGuid-gated stale-velocity anim-cycle stop. See +/// 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: @@ -68,33 +66,87 @@ internal sealed class RemotePhysicsUpdater AcDream.App.World.LiveEntityRuntime liveEntities, uint localPlayerServerGuid, float dt, - System.Action publishRootPose) + System.Action publishRootPose, + System.Action? + processAnimationHooks = null, + System.Action? markPartPoseDirty = null) { ArgumentNullException.ThrowIfNull(liveEntities); ArgumentNullException.ThrowIfNull(publishRootPose); + System.Numerics.Vector3? playerPosition = null; + if (localPlayerServerGuid != 0 + && liveEntities.TryGetWorldEntity( + localPlayerServerGuid, + out AcDream.Core.World.WorldEntity playerEntity)) + { + playerPosition = playerEntity.Position; + } + liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot); foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot) { if (record.ServerGuid == localPlayerServerGuid || (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0 - || !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid) || record.RemoteMotionRuntime is not RemoteMotion remote || !liveEntities.IsCurrentSpatialRemoteMotion(record, remote) || record.WorldEntity is not { } entity) continue; - System.Action? handlePartArray = record.AnimationRuntime is AnimatedEntity - { Sequencer: { } sequencer } - ? sequencer.Manager.UseTime - : null; - TickHidden(remote, entity, dt, handlePartArray); + AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity; + AcDream.Core.Physics.RetailObjectActivityResult activity = + AcDream.Core.Physics.RetailObjectActivityGate.Evaluate( + record.ObjectClock, + remote.Body, + liveEntities.GetRootObjectClockDisposition(record.ServerGuid) + is AcDream.Core.Physics.RetailObjectClockDisposition.Advance, + hasPartArray: record.HasPartArray, + isStatic: (record.FinalPhysicsState + & AcDream.Core.Physics.PhysicsStateFlags.Static) != 0, + entity.Position, + playerPosition, + dt); + if (activity is not AcDream.Core.Physics.RetailObjectActivityResult.Active) + continue; + + AcDream.Core.Physics.AnimationSequencer? sequencer = animation?.Sequencer; + ulong objectClockEpoch = record.ObjectClockEpoch; + AcDream.Core.Physics.RetailObjectQuantumBatch batch = + record.ObjectClock.Advance(dt); + for (int qi = 0; qi < batch.Count; qi++) + { + if (!liveEntities.IsCurrentSpatialRemoteMotion(record, remote) + || !ReferenceEquals(record.WorldEntity, entity) + || record.ObjectClockEpoch != objectClockEpoch) + break; + if (!TickHidden( + remote, + entity, + batch.GetQuantum(qi), + sequencer?.Manager, + processAnimationHooks, + sequencer, + liveEntities, + record, + objectClockEpoch)) + { + break; + } + } // PositionManager callbacks can rebucket, delete, or replace this // GUID. Publish only while the captured record/runtime pair still // owns a loaded spatial projection. - if (liveEntities.IsCurrentSpatialRemoteMotion(record, remote) - && ReferenceEquals(record.WorldEntity, entity)) + if (batch.Count > 0 + && liveEntities.IsCurrentSpatialRemoteMotion(record, remote) + && ReferenceEquals(record.WorldEntity, entity) + && record.ObjectClockEpoch == objectClockEpoch) { + // Hidden suppresses CPartArray::Update, but HandleEnterWorld + // may already have replaced the current sequence pose. Tell + // the presentation pass to sample that retained pose once for + // this admitted object quantum rather than rebuilding it on + // every render frame. + markPartPoseDirty?.Invoke(record.ServerGuid); publishRootPose(entity); } } @@ -106,28 +158,75 @@ internal sealed class RemotePhysicsUpdater /// body. serverGuid + the entity id derive from /// .Entity; / /// are passed per-call (they change on streaming recentre — never snapshot - /// them in the constructor). is - /// the complete local displacement produced by this frame's preceding + /// them in the constructor). is + /// the complete local Frame produced by this frame's preceding /// CSequence::update, matching retail's /// CPartArray::Update → PositionManager::adjust_offset order. /// - public void Tick( + public bool Tick( RemoteMotion rm, AnimatedEntity ae, float dt, - System.Numerics.Vector3 rootMotionLocalDelta, + AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, int liveCenterX, - int liveCenterY) + int liveCenterY, + System.Action? + processAnimationHooks = null) + => Tick( + rm, + ae.Entity, + ae.Scale, + ae.Sequencer, + ae, + dt, + rootMotionLocalFrame, + liveCenterX, + liveCenterY, + processAnimationHooks); + + /// + /// Canonical ordinary-object tick. Render animation is optional: retail + /// walks CPhysics::object_maint, so a live object with a + /// MovementManager or PositionManager must continue even when it has no + /// AnimatedEntity presentation component. + /// + public bool Tick( + RemoteMotion rm, + AcDream.Core.World.WorldEntity entity, + float objectScale, + AcDream.Core.Physics.AnimationSequencer? sequencer, + AnimatedEntity? animationForVelocityCycle, + float dt, + AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, + int liveCenterX, + int liveCenterY, + System.Action? + processAnimationHooks = null, + AcDream.App.World.LiveEntityRuntime? ownerRuntime = null, + AcDream.App.World.LiveEntityRecord? ownerRecord = null, + ulong ownerClockEpoch = 0) { - uint serverGuid = ae.Entity.ServerGuid; + ArgumentNullException.ThrowIfNull(rm); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(rootMotionLocalFrame); + if (!IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch)) + { + return false; + } + uint serverGuid = entity.ServerGuid; // #184 Slice 2b — the UNIFIED per-remote tick. The former Path A // (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition // sweep OMITTED, per the now-retired issue-#40 "collision is the sender's // problem" premise) is GONE — every remote now runs the SAME catch-up + // sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap // exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO - // player/remote fork; the only surviving player/NPC split is the omega - // handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop. + // player/remote fork; only the stale animation-cycle stop below remains + // player/NPC-specific. // // Stop detection stays explicit on packet receipt (UpdateMotion // ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared -> @@ -143,8 +242,8 @@ internal sealed class RemotePhysicsUpdater // remotes below are explicitly OnWalkable and airborne remotes use // their authoritative velocity/gravity arc, so this is the same // branch expressed through our retained runtime state. - System.Numerics.Vector3 scaledRootMotionLocalDelta = !rm.Airborne - ? rootMotionLocalDelta * ae.Scale + System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne + ? rootMotionLocalFrame.Origin * objectScale : System.Numerics.Vector3.Zero; // Step 1: re-apply current motion commands → body.Velocity. @@ -199,11 +298,14 @@ internal sealed class RemotePhysicsUpdater { rm.ServerVelocity = System.Numerics.Vector3.Zero; rm.HasServerVelocity = false; - _applyServerControlledVelocityCycle( - serverGuid, - ae, - rm, - System.Numerics.Vector3.Zero); + if (animationForVelocityCycle is not null) + { + _applyServerControlledVelocityCycle( + serverGuid, + animationForVelocityCycle, + rm, + System.Numerics.Vector3.Zero); + } } } @@ -223,58 +325,10 @@ internal sealed class RemotePhysicsUpdater rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - // Step 2: integrate rotation manually per tick. We can't - // rely on PhysicsBody.update_object here — its MinQuantum - // gate (1/30 s) causes it to SKIP integration when our - // 60fps render dt (~0.016s) is below the quantum, meaning - // rotation never advances. Measured snap per UP was ~129° - // = the full expected 1s × 2.24 rad/s, confirming zero - // between-tick rotation. - // - // Manual integration matches retail's FUN_005256b0 - // apply_physics (Orientation *= quat(ω × dt)). Use - // ObservedOmega derived from server UP rotation deltas so - // the rate exactly matches server physics — hard-snap on - // next UP becomes invisible by construction. - // #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback + - // world-frame (pre-multiply, Concatenate) application inherited from the - // former Path A — a circling player sends RunForward+TurnLeft on ONE UM - // whose RunForward cycle synthesises zero omega, so ObservedOmega (from - // the wire TurnCommand) must carry the turn or the body would not rotate - // between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies - // keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a - // seqOmega fallback would change NPC turning (handoff 4.1), so the split - // is preserved. For an upright body + a yaw (world-Z) omega the two - // multiplication orders commute, so this fork is faithful, not cosmetic. - // calc_acceleration zeroes Body.Omega for grounded bodies before - // UpdatePhysicsInternal; the explicit zero here covers the airborne case - // (a wire-set Body.Omega would otherwise double-integrate on top of the - // manual rotation). - rm.Body.Omega = System.Numerics.Vector3.Zero; - if (IsPlayerGuid(serverGuid) && !rm.Airborne) - { - System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega - ?? System.Numerics.Vector3.Zero; - System.Numerics.Vector3 omegaToApply = - rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega; - if (omegaToApply.LengthSquared() > 1e-9f) - { - float angleDelta = omegaToApply.Length() * (float)dt; - System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply); - var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta); - rm.Body.Orientation = System.Numerics.Quaternion.Normalize( - System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot)); - } - } - else if (rm.ObservedOmega.LengthSquared() > 1e-8f) - { - float omegaMag = rm.ObservedOmega.Length(); - var axis = rm.ObservedOmega / omegaMag; - float angle = omegaMag * dt; - var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); - rm.Body.Orientation = System.Numerics.Quaternion.Normalize( - System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot)); - } + // Step 2: CSequence's complete Frame carries motion-table omega through + // the same compose as root translation. PhysicsBody.Omega remains + // reserved for the object's physical angular velocity and is + // integrated once by UpdatePhysicsInternal below. // Step 3: integrate physics — retail FUN_005111D0 // UpdatePhysicsInternal. Pure Euler: @@ -289,11 +343,10 @@ internal sealed class RemotePhysicsUpdater // from the UP hard-snap, producing a visible teleport-stride // on slopes (the "staircase" the user reported). // - // PlayerMovementController.cs:358 calls UpdatePhysicsInternal - // directly for the same reason. Remote motion mirrors that. - // Omega is already integrated manually above, so we zero it - // here to prevent UpdatePhysicsInternal's own omega pass from - // double-integrating. + // PlayerMovementController calls UpdatePhysicsInternal directly + // for the same reason. Remote motion mirrors that. CSequence's + // authored orientation has already entered the shared delta Frame; + // Body.Omega remains the separate physical angular-velocity source. var preIntegratePos = rm.Body.Position; // R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation → // Sticky over ONE shared delta frame (PositionManager::adjust_offset @@ -312,54 +365,73 @@ internal sealed class RemotePhysicsUpdater // adds no translation on top of the catch-up — no double-move. if (rm.Host is { } npcHost) { - AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta; - if (!rm.Airborne) - { - float maxSpeedNpc = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3? terrainNormalNpc = - _physicsEngine.SampleTerrainNormal( - rm.Body.Position.X, rm.Body.Position.Y); - System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset( - dt: (double)dt, - currentBodyPosition: rm.Body.Position, - rootMotionLocalDelta: scaledRootMotionLocalDelta, - ori: rm.Body.Orientation, - interp: rm.Interp, - maxSpeed: maxSpeedNpc, - terrainNormal: terrainNormalNpc); - pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame - { - Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( - rm.Body.Orientation, offsetNpc), - }; - } - else - { - pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); - } + AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta = + rm.PositionManagerDeltaScratch; + pmDelta.Origin = scaledRootMotionLocalOrigin; + pmDelta.Orientation = rootMotionLocalFrame.Orientation; + float maxSpeedNpc = rm.Motion.GetMaxSpeed(); + System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne + ? _physicsEngine.SampleTerrainNormal( + rm.Body.Position.X, + rm.Body.Position.Y) + : null; + rm.Position.ComposeOffset( + dt, + rm.Body.Position, + rm.Body.Orientation, + pmDelta, + rm.Interp, + maxSpeedNpc, + pmDelta, + terrainNormalNpc, + inContact: rm.Body.InContact); npcHost.PositionManager.AdjustOffset(pmDelta, dt); ApplyPositionManagerDelta(rm.Body, pmDelta); } - else if (!rm.Airborne) + else { // No PositionManager host yet (pre-binding): apply the catch-up // directly, matching Path A's fallback (:10202). + // Airborne suppresses only CPartArray Origin. Retail keeps the + // complete root orientation live across the OnWalkable scale + // gate in UpdatePositionInternal (0x00512C30). + AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta = + rm.PositionManagerDeltaScratch; + pmDelta.Origin = scaledRootMotionLocalOrigin; + pmDelta.Orientation = rootMotionLocalFrame.Orientation; float maxSpeedNpc = rm.Motion.GetMaxSpeed(); - System.Numerics.Vector3? terrainNormalNpc = - _physicsEngine.SampleTerrainNormal( - rm.Body.Position.X, rm.Body.Position.Y); - rm.Body.Position += rm.Position.ComputeOffset( - dt: (double)dt, - currentBodyPosition: rm.Body.Position, - rootMotionLocalDelta: scaledRootMotionLocalDelta, - ori: rm.Body.Orientation, - interp: rm.Interp, - maxSpeed: maxSpeedNpc, - terrainNormal: terrainNormalNpc); + System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne + ? _physicsEngine.SampleTerrainNormal( + rm.Body.Position.X, + rm.Body.Position.Y) + : null; + rm.Position.ComposeOffset( + dt, + rm.Body.Position, + rm.Body.Orientation, + pmDelta, + rm.Interp, + maxSpeedNpc, + pmDelta, + terrainNormalNpc, + inContact: rm.Body.InContact); + ApplyPositionManagerDelta(rm.Body, pmDelta); } rm.Body.calc_acceleration(); rm.Body.UpdatePhysicsInternal(dt); + if (sequencer is { } hookSequencer) + processAnimationHooks?.Invoke(entity.Id, hookSequencer); + if (!IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch)) + { + return false; + } var postIntegratePos = rm.Body.Position; + uint committedCellId = rm.CellId; // Step 4: collision sweep — retail FUN_00514B90 → // FUN_005148A0 → Transition::FindTransitionalPosition. @@ -399,7 +471,7 @@ internal sealed class RemotePhysicsUpdater // Fallback to the human capsule for a shapeless / unresolvable // Setup (GetSetupCylinder returns (0,0)); a zero radius would // degenerate the sweep. - var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity); + var (deR, deH) = _getSetupCylinder(serverGuid, entity); if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; } var resolveResult = _physicsEngine.ResolveWithTransition( preIntegratePos, postIntegratePos, rm.CellId, @@ -442,11 +514,11 @@ internal sealed class RemotePhysicsUpdater // the remote's own cylinder and produces ~1m of // horizontal drift on the first jump frame // (validated by [SWEEP-OBJ] traces). - movingEntityId: ae.Entity.Id); + movingEntityId: entity.Id); rm.Body.Position = resolveResult.Position; if (resolveResult.CellId != 0) - rm.CellId = resolveResult.CellId; + committedCellId = resolveResult.CellId; // #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing // de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail @@ -460,9 +532,9 @@ internal sealed class RemotePhysicsUpdater // player would collide with a shadow offset from where the monster // renders (the reverted attempt's "stuck on an invisible monster"). // Syncing the shadow to the resolved body every tick makes the - // de-overlap PERSIST and keeps collision == render. Re-flood is cheap - // MOVEMENT-GATED (#184 review): re-flood only when the resolved - // body moved > ~1 cm since the last shadow registration. This is + // de-overlap PERSIST and keeps collision == render. Re-flood is + // POSE/CELL-GATED (#184 review): re-flood only when the resolved + // body moved > ~1 cm, rotated, or entered another cell. This is // SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for // players too — every remote's shadow (player + NPC) is written ONLY // by this loop + the UP-branch tail, both to the resolved body, so a @@ -473,12 +545,6 @@ internal sealed class RemotePhysicsUpdater // to actually-moving remotes — the perf risk the review flagged for // a packed town. (In-place shadow-move + cell-relink-on-change is a // further optimization if profiling still shows churn.) - if (System.Numerics.Vector3.DistanceSquared( - rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f) - { - SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY); - } - // #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions // (pc:282699-282715) runs after EVERY SetPositionInternal — // remote objects included; a VectorUpdate-launched jump arc @@ -578,21 +644,69 @@ internal sealed class RemotePhysicsUpdater // airborne UseTime contact gate; without it a // chasing NPC that lands stalls until ACE's // ~1 Hz re-emit. + ulong landingStateAuthorityVersion = + ownerRecord?.StateAuthorityVersion ?? 0UL; rm.Movement.HitGround(); + if (!IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch)) + { + return false; + } // DR bookkeeping only (partner of the jump-start // `State |= Gravity`): stops the per-tick gravity // integration for the grounded body. - rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + if (ownerRecord is null + || ownerRecord.StateAuthorityVersion + == landingStateAuthorityVersion) + { + rm.Body.State &= + ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + } if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); } } - ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty - if (rm.CellId != 0) - ae.Entity.ParentCellId = rm.CellId; - ae.Entity.Rotation = rm.Body.Orientation; + // SetPositionInternal commits the resolved body/contact result and + // root frame as one object before changing cell membership. The + // canonical CellId writer can synchronously rebucket loaded to + // pending, delete, or replace this GUID, so it is the transaction's + // final callback boundary before shadow publication. + entity.SetPosition(rm.Body.Position); + entity.ParentCellId = committedCellId; + entity.Rotation = rm.Body.Orientation; + bool cellChanged = committedCellId != 0 + && committedCellId != rm.CellId; + if (cellChanged) + rm.CellId = committedCellId; + if (!IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch)) + { + return false; + } + + // #184: shadow follows the resolved body, but only after the + // canonical rebucket proves this exact owner is still spatially + // resident. A pending destination keeps its retained registration + // suspended; a callback-created replacement owns another local ID. + if (ShouldSynchronizeShadow( + cellChanged, + rm.Body.Position, + rm.Body.Orientation, + rm.LastShadowSyncPos, + rm.LastShadowSyncOrientation)) + { + SyncRemoteShadowToBody(entity.Id, rm, liveCenterX, liveCenterY); + } } // R5-V3 (#171): retail UpdateObjectInternal tail — @@ -602,21 +716,17 @@ internal sealed class RemotePhysicsUpdater // sticky 1 s lease watchdog (StickyManager::UseTime // 0x00555610 — a stick not re-issued by a fresh server arm // within 1 s tears itself down). No-op while nothing is stuck. - System.Action? handleTargeting = rm.Host is { } targetHost - ? targetHost.HandleTargetting - : null; - System.Action? partArrayHandleMovement = ae.Sequencer is { } sequencer - ? sequencer.Manager.UseTime - : null; - System.Action? positionUseTime = rm.Host is { } positionHost - ? positionHost.PositionManager.UseTime - : null; AcDream.Core.Physics.RetailObjectManagerTail.Run( - checkDetection: null, - handleTargeting, - movementUseTime: rm.Movement.UseTime, - partArrayHandleMovement, - positionUseTime); + rm.Host?.TargetManager, + rm.Movement, + sequencer?.Manager, + rm.Host?.PositionManager); + return IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch); } /// @@ -628,37 +738,69 @@ internal sealed class RemotePhysicsUpdater /// position managers still consume time. Physics-script and particle owners /// tick later in the shared frame pipeline. /// - public void TickHidden( + public bool TickHidden( RemoteMotion rm, AcDream.Core.World.WorldEntity entity, float dt, - System.Action? partArrayHandleMovement = null) + AcDream.Core.Physics.Motion.MotionTableManager? + partArrayHandleMovement = null, + System.Action? + processAnimationHooks = null, + AcDream.Core.Physics.AnimationSequencer? sequencer = null, + AcDream.App.World.LiveEntityRuntime? ownerRuntime = null, + AcDream.App.World.LiveEntityRecord? ownerRecord = null, + ulong ownerClockEpoch = 0) { ArgumentNullException.ThrowIfNull(rm); ArgumentNullException.ThrowIfNull(entity); + if (!IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch)) + { + return false; + } System.Numerics.Vector3 preComposePosition = rm.Body.Position; // The part-array contribution is the identity frame while Hidden. // Interpolation is the first PositionManager stage in retail; acdream // retains it in RemoteMotionCombiner, ahead of Sticky/Constraint. - System.Numerics.Vector3 interpolationOffset = rm.Position.ComputeOffset( + AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta = + rm.PositionManagerDeltaScratch; + positionDelta.Reset(); + rm.Position.ComposeOffset( dt, rm.Body.Position, - System.Numerics.Vector3.Zero, rm.Body.Orientation, + positionDelta, rm.Interp, - rm.Motion.GetMaxSpeed()); - var positionDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame - { - Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( - rm.Body.Orientation, - interpolationOffset), - }; + rm.Motion.GetMaxSpeed(), + positionDelta, + inContact: rm.Body.InContact); rm.Host?.PositionManager.AdjustOffset(positionDelta, dt); ApplyPositionManagerDelta(rm.Body, positionDelta); + // Hidden suppresses CPartArray::Update, but process_hooks remains the + // final UpdatePositionInternal step. Drain any hook already pending on + // the retained sequence before transition/manager time, exactly like + // the visible path above. + if (sequencer is not null) + processAnimationHooks?.Invoke(entity.Id, sequencer); + if (!IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch)) + { + return false; + } + System.Numerics.Vector3 composedPosition = rm.Body.Position; + uint committedCellId = rm.CellId; if (rm.CellId != 0 && composedPosition != preComposePosition && _physicsEngine.LandblockCount > 0) @@ -689,8 +831,8 @@ internal sealed class RemotePhysicsUpdater movingEntityId: entity.Id); rm.Body.Position = resolved.Position; if (resolved.CellId != 0) - rm.CellId = resolved.CellId; - AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition( + committedCellId = resolved.CellId; + if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition( rm.Body, resolved.InContact, resolved.OnWalkable, @@ -699,39 +841,71 @@ internal sealed class RemotePhysicsUpdater previousContact, previousOnWalkable, rm.Movement.HitGround, - rm.Motion.LeaveGround); + rm.Motion.LeaveGround, + () => IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch))) + { + return false; + } rm.Airborne = !rm.Body.OnWalkable; } + // Hidden suppresses mesh/part updates, not SetPositionInternal. Commit + // the resolved root before the canonical cell writer enters the same + // re-entrant rebucket boundary as the visible path. entity.SetPosition(rm.Body.Position); - if (rm.CellId != 0) - entity.ParentCellId = rm.CellId; + entity.ParentCellId = committedCellId; entity.Rotation = rm.Body.Orientation; + if (committedCellId != 0 && committedCellId != rm.CellId) + rm.CellId = committedCellId; + if (!IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch)) + { + return false; + } - System.Action? handleTargeting = rm.Host is { } targetHost - ? targetHost.HandleTargetting - : null; - System.Action? positionUseTime = rm.Host is { } positionHost - ? positionHost.PositionManager.UseTime - : null; AcDream.Core.Physics.RetailObjectManagerTail.Run( - checkDetection: null, - handleTargeting, - movementUseTime: rm.Movement.UseTime, + rm.Host?.TargetManager, + rm.Movement, partArrayHandleMovement, - positionUseTime); + rm.Host?.PositionManager); + return IsCurrentOwner( + ownerRuntime, + ownerRecord, + rm, + entity, + ownerClockEpoch); } + private static bool IsCurrentOwner( + AcDream.App.World.LiveEntityRuntime? ownerRuntime, + AcDream.App.World.LiveEntityRecord? ownerRecord, + RemoteMotion remote, + AcDream.Core.World.WorldEntity entity, + ulong ownerClockEpoch) => + ownerRuntime is null + || ownerRecord is null + || (ownerRecord.ObjectClockEpoch == ownerClockEpoch + && ownerRuntime.IsCurrentSpatialRemoteMotion(ownerRecord, remote) + && ReferenceEquals(ownerRecord.WorldEntity, entity)); + /// /// R5-V3 (#171): apply a /// written by PositionManager.AdjustOffset onto a body — acdream's /// stand-in for retail's Frame::combine in /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes - /// globaltolocalvec output — 0x00555430), so combining = rotating it - /// out by the body orientation. An untouched (identity) rotation means "no - /// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition - /// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the + /// globaltolocalvec output — 0x00555430), so combining rotates it + /// out by the body's current orientation and post-multiplies the complete + /// relative orientation. Moved from GameWindow (#184 Slice 2a); the /// DR tick is its only caller. /// private static void ApplyPositionManagerDelta( @@ -741,10 +915,10 @@ internal sealed class RemotePhysicsUpdater if (delta.Origin != System.Numerics.Vector3.Zero) body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation); if (!delta.Orientation.IsIdentity) - body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( + body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( + body.Position, body.Orientation, - AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation) - + delta.GetHeading()); + body.Orientation * delta.Orientation); } /// @@ -756,7 +930,10 @@ internal sealed class RemotePhysicsUpdater /// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is /// passed in (/) /// rather than snapshotted, since it moves on recentre. Updates - /// so callers can movement-gate. + /// and + /// so callers can + /// pose-gate. Rotation matters because Setup collision geometry may be + /// multipart or offset from the root. /// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC /// UP-branch tail. /// @@ -774,8 +951,53 @@ internal sealed class RemotePhysicsUpdater liveCenterY, authoritativeCellId ?? rm.CellId); rm.LastShadowSyncPosition = rm.Body.Position; + rm.LastShadowSyncOrientation = rm.Body.Orientation; } + internal static bool ShouldSynchronizeShadowPose( + System.Numerics.Vector3 currentPosition, + System.Numerics.Quaternion currentOrientation, + System.Numerics.Vector3 lastPosition, + System.Numerics.Quaternion lastOrientation) + { + if (System.Numerics.Vector3.DistanceSquared( + currentPosition, + lastPosition) > 1e-4f) + { + return true; + } + + float currentLengthSquared = currentOrientation.LengthSquared(); + float lastLengthSquared = lastOrientation.LengthSquared(); + if (!float.IsFinite(currentLengthSquared) + || !float.IsFinite(lastLengthSquared) + || currentLengthSquared < 1e-12f + || lastLengthSquared < 1e-12f) + { + return true; + } + + float normalizedDot = MathF.Abs( + System.Numerics.Quaternion.Dot( + currentOrientation, + lastOrientation) + / MathF.Sqrt(currentLengthSquared * lastLengthSquared)); + return !float.IsFinite(normalizedDot) || normalizedDot < 0.99999f; + } + + internal static bool ShouldSynchronizeShadow( + bool cellChanged, + System.Numerics.Vector3 currentPosition, + System.Numerics.Quaternion currentOrientation, + System.Numerics.Vector3 lastPosition, + System.Numerics.Quaternion lastOrientation) => + cellChanged + || ShouldSynchronizeShadowPose( + currentPosition, + currentOrientation, + lastPosition, + lastOrientation); + public void SyncRemoteShadowToBody( uint entityId, AcDream.Core.Physics.PhysicsBody body, diff --git a/src/AcDream.App/Physics/RemoteTeleportController.cs b/src/AcDream.App/Physics/RemoteTeleportController.cs index 4fada684..2af3177a 100644 --- a/src/AcDream.App/Physics/RemoteTeleportController.cs +++ b/src/AcDream.App/Physics/RemoteTeleportController.cs @@ -65,9 +65,13 @@ internal sealed class RemoteTeleportController : IDisposable bool ContactResolved, Vector3 Position, uint CellId, - Quaternion Orientation); + Quaternion Orientation, + bool Superseded = false); private readonly record struct PendingPlacement( + LiveEntityRecord Record, + ulong PositionAuthorityVersion, + ulong VelocityAuthorityVersion, ILiveEntityRemotePlacementRuntime Remote, PhysicsBody Body, WorldEntity Entity, @@ -95,9 +99,53 @@ internal sealed class RemoteTeleportController : IDisposable bool Airborne, Vector3 LastServerPosition, double LastServerPositionTime, - Vector3 LastShadowSyncPosition); + Vector3 LastShadowSyncPosition, + Quaternion LastShadowSyncOrientation); + + /// + /// Applies a placement to whichever exact incarnation is current at this + /// call boundary. Packet handlers should use the token-bearing overload; + /// this form serves non-wire orchestration and retains the same callback + /// revalidation once admitted. + /// + internal Result TryApply( + ILiveEntityRemotePlacementRuntime remote, + WorldEntity entity, + Vector3 requestedWorldPosition, + uint requestedCellId, + Vector3 requestedCellLocalPosition, + Quaternion requestedOrientation, + double gameTime, + bool destinationProjectionVisible, + ushort generation, + ushort positionSequence) + { + if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record)) + { + throw new InvalidOperationException( + $"Remote placement for 0x{entity.ServerGuid:X8} requires a live incarnation."); + } + + return TryApply( + record, + record.PositionAuthorityVersion, + record.VelocityAuthorityVersion, + remote, + entity, + requestedWorldPosition, + requestedCellId, + requestedCellLocalPosition, + requestedOrientation, + gameTime, + destinationProjectionVisible, + generation, + positionSequence); + } internal Result TryApply( + LiveEntityRecord expectedRecord, + ulong expectedPositionAuthorityVersion, + ulong expectedVelocityAuthorityVersion, ILiveEntityRemotePlacementRuntime remote, WorldEntity entity, Vector3 requestedWorldPosition, @@ -111,8 +159,15 @@ internal sealed class RemoteTeleportController : IDisposable { ArgumentNullException.ThrowIfNull(remote); ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(expectedRecord); if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord) + || !ReferenceEquals(liveRecord, expectedRecord) + || !_liveEntities.IsCurrentPositionAuthority( + expectedRecord, + expectedPositionAuthorityVersion) || liveRecord.Generation != generation + || !ReferenceEquals(liveRecord.WorldEntity, entity) + || !ReferenceEquals(liveRecord.RemoteMotionRuntime, remote) || liveRecord.PhysicsBody is null || !ReferenceEquals(liveRecord.PhysicsBody, remote.Body)) { @@ -124,6 +179,7 @@ internal sealed class RemoteTeleportController : IDisposable bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable; RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody); if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior) + && ReferenceEquals(prior.Record, liveRecord) && prior.Generation == generation) { wasInContact = prior.WasInContact; @@ -132,6 +188,9 @@ internal sealed class RemoteTeleportController : IDisposable } PendingPlacement request = new( + liveRecord, + expectedPositionAuthorityVersion, + expectedVelocityAuthorityVersion, remote, liveRecord.PhysicsBody, entity, @@ -146,7 +205,8 @@ internal sealed class RemoteTeleportController : IDisposable rollback); if (!destinationProjectionVisible) { - ParkPending(request, requestedCellLocalPosition); + if (!ParkPending(request, requestedCellLocalPosition)) + return Superseded(request); return new Result( true, false, @@ -155,10 +215,13 @@ internal sealed class RemoteTeleportController : IDisposable requestedOrientation); } ResolveResult placement = Resolve(request); + if (!IsCurrent(request)) + return Superseded(request); if (!placement.Ok) { _pending.Remove(entity.ServerGuid); - RollBackDeferredPlacement(request); + if (!RollBackDeferredPlacement(request)) + return Superseded(request); return new Result( false, false, @@ -250,7 +313,9 @@ internal sealed class RemoteTeleportController : IDisposable private Result CommitResolved(PendingPlacement request, ResolveResult placement) { - RemoteTeleportPlacement.Apply( + if (!IsCurrent(request)) + return Superseded(request); + if (!RemoteTeleportPlacement.Apply( request.Remote, request.Body, placement, @@ -258,16 +323,29 @@ internal sealed class RemoteTeleportController : IDisposable request.RequestedOrientation, request.GameTime, request.WasInContact, - request.WasOnWalkable); + request.WasOnWalkable, + () => IsCurrent(request), + () => IsVelocityCurrent(request))) + { + return Superseded(request); + } + if (!IsCurrent(request)) + return Superseded(request); if (request.Remote.CellId != placement.CellId) request.Remote.CellId = placement.CellId; + if (!IsCurrent(request)) + return Superseded(request); request.Remote.LastServerPosition = placement.Position; request.Remote.LastServerPositionTime = request.GameTime; request.Remote.LastShadowSyncPosition = Vector3.Zero; + request.Remote.LastShadowSyncOrientation = Quaternion.Zero; request.Entity.SetPosition(request.Body.Position); request.Entity.ParentCellId = placement.CellId; request.Entity.Rotation = request.Body.Orientation; - if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record)) + if (!IsCurrent(request)) + return Superseded(request); + if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record) + && ReferenceEquals(record, request.Record)) { bool deferShadowRestore = record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden) @@ -276,10 +354,15 @@ internal sealed class RemoteTeleportController : IDisposable request.Entity.ServerGuid, request.Generation, deferShadowRestore); + if (!IsCurrent(request)) + return Superseded(request); if (!deferShadowRestore) { request.Remote.LastShadowSyncPosition = request.Body.Position; + request.Remote.LastShadowSyncOrientation = request.Body.Orientation; _syncResolvedShadow(request.Entity, request.Body, placement.CellId); + if (!IsCurrent(request)) + return Superseded(request); } } return new Result( @@ -290,8 +373,10 @@ internal sealed class RemoteTeleportController : IDisposable request.Body.Orientation); } - private void ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition) + private bool ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition) { + if (!IsCurrent(request)) + return false; request.Body.Orientation = request.RequestedOrientation; request.Body.SnapToCell( request.RequestedCellId, @@ -308,13 +393,19 @@ internal sealed class RemoteTeleportController : IDisposable request.Remote.Airborne = true; if (request.Remote.CellId != request.RequestedCellId) request.Remote.CellId = request.RequestedCellId; + if (!IsCurrent(request)) + return false; request.Remote.LastServerPosition = request.RequestedWorldPosition; request.Remote.LastServerPositionTime = request.GameTime; request.Remote.LastShadowSyncPosition = Vector3.Zero; + request.Remote.LastShadowSyncOrientation = Quaternion.Zero; request.Entity.SetPosition(request.RequestedWorldPosition); request.Entity.ParentCellId = request.RequestedCellId; request.Entity.Rotation = request.RequestedOrientation; + if (!IsCurrent(request)) + return false; _pending[request.Entity.ServerGuid] = request; + return true; } private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) @@ -325,6 +416,7 @@ internal sealed class RemoteTeleportController : IDisposable if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending)) { if (record.WorldEntity is null + || !ReferenceEquals(record, pending.Record) || !ReferenceEquals(record.WorldEntity, pending.Entity) || record.Generation != pending.Generation) { @@ -351,7 +443,10 @@ internal sealed class RemoteTeleportController : IDisposable pending = pending with { Remote = currentRemote }; _pending[record.ServerGuid] = pending; } - if (record.Snapshot.PositionSequence != pending.PositionSequence) + if (!_liveEntities.IsCurrentPositionAuthority( + pending.Record, + pending.PositionAuthorityVersion) + || record.Snapshot.PositionSequence != pending.PositionSequence) { // A newer accepted UpdatePosition rebucketed the projection // before its TryApply call could replace this request. Keep @@ -361,6 +456,8 @@ internal sealed class RemoteTeleportController : IDisposable _pending.Remove(record.ServerGuid); ResolveResult placement = Resolve(pending); + if (!IsCurrent(pending)) + return; if (!placement.Ok) RollBackDeferredPlacement(pending); else @@ -388,11 +485,14 @@ internal sealed class RemoteTeleportController : IDisposable remote.Airborne, remote.LastServerPosition, remote.LastServerPositionTime, - remote.LastShadowSyncPosition); + remote.LastShadowSyncPosition, + remote.LastShadowSyncOrientation); } - private void RollBackDeferredPlacement(PendingPlacement pending) + private bool RollBackDeferredPlacement(PendingPlacement pending) { + if (!IsCurrentAuthority(pending)) + return false; RollbackPlacement rollback = pending.Rollback; PhysicsBody body = pending.Body; body.Orientation = rollback.Orientation; @@ -412,21 +512,26 @@ internal sealed class RemoteTeleportController : IDisposable pending.Remote.LastServerPosition = rollback.LastServerPosition; pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime; pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition; + pending.Remote.LastShadowSyncOrientation = rollback.LastShadowSyncOrientation; pending.Entity.SetPosition(rollback.Position); pending.Entity.ParentCellId = rollback.CellId; pending.Entity.Rotation = rollback.Orientation; + if (!IsCurrentAuthority(pending)) + return false; if (rollback.CellId == 0) _liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid); else _liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId); - if (!_liveEntities.TryGetRecord( + if (!IsCurrentAuthority(pending) + || !_liveEntities.TryGetRecord( pending.Entity.ServerGuid, out LiveEntityRecord restored) + || !ReferenceEquals(restored, pending.Record) || restored.Generation != pending.Generation) { - return; + return false; } if (rollback.CellId == 0) @@ -435,7 +540,7 @@ internal sealed class RemoteTeleportController : IDisposable pending.Entity.ServerGuid, pending.Generation, false); - return; + return IsCurrentAuthority(pending); } bool deferShadowRestore = @@ -445,13 +550,42 @@ internal sealed class RemoteTeleportController : IDisposable pending.Entity.ServerGuid, pending.Generation, deferShadowRestore); + if (!IsCurrentAuthority(pending)) + return false; if (deferShadowRestore) - return; + return true; pending.Remote.LastShadowSyncPosition = Vector3.Zero; + pending.Remote.LastShadowSyncOrientation = Quaternion.Zero; _syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId); + return IsCurrentAuthority(pending); } + private bool IsCurrentAuthority(PendingPlacement request) => + _liveEntities.IsCurrentPositionAuthority( + request.Record, + request.PositionAuthorityVersion) + && request.Record.Generation == request.Generation + && ReferenceEquals(request.Record.WorldEntity, request.Entity) + && ReferenceEquals(request.Record.PhysicsBody, request.Body); + + private bool IsCurrent(PendingPlacement request) => + IsCurrentAuthority(request) + && ReferenceEquals(request.Record.RemoteMotionRuntime, request.Remote); + + private bool IsVelocityCurrent(PendingPlacement request) => + _liveEntities.IsCurrentVelocityAuthority( + request.Record, + request.VelocityAuthorityVersion); + + private static Result Superseded(PendingPlacement request) => new( + Applied: false, + ContactResolved: false, + Position: request.Body.Position, + CellId: request.Remote.CellId, + Orientation: request.Body.Orientation, + Superseded: true); + private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; } diff --git a/src/AcDream.App/Physics/RemoteTeleportHook.cs b/src/AcDream.App/Physics/RemoteTeleportHook.cs index 39b16299..a0b54769 100644 --- a/src/AcDream.App/Physics/RemoteTeleportHook.cs +++ b/src/AcDream.App/Physics/RemoteTeleportHook.cs @@ -12,7 +12,9 @@ public static class RemoteTeleportHook { private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu; - public static void Execute(RemoteTeleportHookActions actions) + public static bool Execute( + RemoteTeleportHookActions actions, + Func? isCurrent = null) { ArgumentNullException.ThrowIfNull(actions.CancelMoveTo); ArgumentNullException.ThrowIfNull(actions.UnStick); @@ -21,12 +23,26 @@ public static class RemoteTeleportHook ArgumentNullException.ThrowIfNull(actions.NotifyTeleported); ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd); + bool Current() => isCurrent?.Invoke() ?? true; + if (!Current()) + return false; actions.CancelMoveTo(TeleportCancelContext); + if (!Current()) + return false; actions.UnStick(); + if (!Current()) + return false; actions.StopInterpolating(); + if (!Current()) + return false; actions.UnConstrain(); + if (!Current()) + return false; actions.NotifyTeleported(); + if (!Current()) + return false; actions.ReportCollisionEnd(); + return Current(); } } diff --git a/src/AcDream.App/Physics/RemoteTeleportPlacement.cs b/src/AcDream.App/Physics/RemoteTeleportPlacement.cs index 33cd6c42..f2fcd759 100644 --- a/src/AcDream.App/Physics/RemoteTeleportPlacement.cs +++ b/src/AcDream.App/Physics/RemoteTeleportPlacement.cs @@ -12,7 +12,7 @@ namespace AcDream.App.Physics; /// internal static class RemoteTeleportPlacement { - internal static void Apply( + internal static bool Apply( ILiveEntityRemotePlacementRuntime remote, PhysicsBody body, ResolveResult placement, @@ -20,7 +20,9 @@ internal static class RemoteTeleportPlacement Quaternion orientation, double gameTime, bool previousContact, - bool previousOnWalkable) + bool previousOnWalkable, + Func? isCurrent = null, + Func? isVelocityCurrent = null) { ArgumentNullException.ThrowIfNull(remote); ArgumentNullException.ThrowIfNull(body); @@ -55,7 +57,7 @@ internal static class RemoteTeleportPlacement body.ContactPlaneIsWater = false; } - PhysicsObjUpdate.CommitSetPositionTransition( + if (!PhysicsObjUpdate.CommitSetPositionTransition( body, placement.InContact, placement.OnWalkable, @@ -64,9 +66,15 @@ internal static class RemoteTeleportPlacement previousContact, previousOnWalkable, remote.HitGround, - remote.LeaveGround); - remote.Airborne = !body.OnWalkable; - + remote.LeaveGround, + isCurrent, + isVelocityCurrent)) + { + return false; + } + if (isVelocityCurrent?.Invoke() != false) + remote.Airborne = !body.OnWalkable; + return isCurrent?.Invoke() ?? true; } private static bool IsFinite(Vector3 value) => diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8ab6d72a..025205c1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -41,6 +41,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter; private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter; private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator; + private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene; /// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters @@ -171,6 +172,11 @@ public sealed class GameWindow : IDisposable // once the injected shared helpers exist as method groups (GetSetupCylinder, // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater; + private readonly AcDream.App.Physics.RemoteInboundMotionDispatcher + _remoteInboundMotion; + private readonly AcDream.App.World.RetailInboundEventDispatcher + _inboundEntityEvents = new(); + private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.OutboundInteractionQueue _outboundInteractions = new(); @@ -321,6 +327,8 @@ public sealed class GameWindow : IDisposable public IReadOnlyList? PreparedSequenceFrames; public bool SequenceAdvancedBeforeAnimationPass; public readonly DatReaderWriter.Types.Frame RootMotionScratch = new(); + public readonly AcDream.Core.Physics.Motion.MotionDeltaFrame + RootMotionDeltaScratch = new(); /// /// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every @@ -487,7 +495,7 @@ public sealed class GameWindow : IDisposable /// /// internal sealed class RemoteMotion : - AcDream.App.World.ILiveEntityRemotePlacementRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it + AcDream.App.World.ILiveEntityRemotePlacementRuntime { public AcDream.Core.Physics.PhysicsBody Body { get; } AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; @@ -522,6 +530,13 @@ public sealed class GameWindow : IDisposable /// public System.Numerics.Vector3 LastShadowSyncPos; /// + /// Complete root orientation paired with . + /// Multipart and off-centre Setup collision geometry must be re-flooded + /// when the object turns in place, not only when its origin translates. + /// The default zero quaternion is the first-sync sentinel. + /// + public System.Numerics.Quaternion LastShadowSyncOrientation; + /// /// Latest server-authoritative velocity for NPC/monster smoothing. /// Prefer the HasVelocity vector from UpdatePosition; when ACE omits /// it for a server-controlled creature, derive it from consecutive @@ -600,14 +615,6 @@ public sealed class GameWindow : IDisposable /// public double LastMoveToPacketTime; /// - /// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed - /// (π/2 × turnSpeed, signed). Applied per tick to body orientation - /// via manual integration (bypassing PhysicsBody.update_object's - /// MinQuantum 30fps gate that would otherwise skip most ticks). - /// Zeroed on UM with TurnCommand absent. - /// - public System.Numerics.Vector3 ObservedOmega; - /// /// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits /// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001- /// 0x0040, indoor 0x0100+). Fed into @@ -657,6 +664,12 @@ public sealed class GameWindow : IDisposable set => LastShadowSyncPos = value; } + System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation + { + get => LastShadowSyncOrientation; + set => LastShadowSyncOrientation = value; + } + bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne { get => Airborne; @@ -699,6 +712,11 @@ public sealed class GameWindow : IDisposable public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } = new AcDream.Core.Physics.RemoteMotionCombiner(); + /// Reusable complete delta frame shared by interpolation, + /// Sticky/Constraint, and the final body composition. + public AcDream.Core.Physics.Motion.MotionDeltaFrame PositionManagerDeltaScratch { get; } = + new AcDream.Core.Physics.Motion.MotionDeltaFrame(); + /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the /// previous UpdatePosition's world position + timestamp. The per-tick @@ -1203,6 +1221,21 @@ public sealed class GameWindow : IDisposable // handler) stay on GameWindow and are injected as delegates. _remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater( _physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle); + _remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher( + RouteServerMoveTo, + StickToObjectFromWire); + var ordinaryPhysicsUpdater = + new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater( + _physicsEngine, + GetSetupCylinder); + _liveAnimationScheduler = new LiveEntityAnimationScheduler( + () => _liveEntities, + () => _playerServerGuid, + _remotePhysicsUpdater, + ordinaryPhysicsUpdater, + () => _projectileController, + entity => _effectPoses.UpdateRoot(entity), + CaptureAnimationHooks); _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( resolveEntity: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) @@ -1212,9 +1245,10 @@ public sealed class GameWindow : IDisposable liveCenterY: () => _liveCenterY, syncShadow: (entity, cellId) => SyncLocalPlayerShadow(entity, cellId), rebucket: (serverGuid, landblockId) => - _liveEntities?.RebucketLiveEntity(serverGuid, landblockId)); + _liveEntities?.RebucketLiveEntity(serverGuid, landblockId), + isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection, + suspendShadow: SuspendLocalPlayerShadow); _localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController( - YawToAcQuaternion, DumpMovementTruthOutbound); _localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController( canPresentPlayer: CanAdvanceLocalPlayer, @@ -1242,7 +1276,10 @@ public sealed class GameWindow : IDisposable _localPlayerOutbound.SendPostNetworkPosition( _liveSession, controller, - hidden)); + hidden), + objectClockDisposition: () => + _liveEntities?.GetRootObjectClockDisposition(_playerServerGuid) + ?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance); _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( AdvanceLiveObjectRuntime, () => @@ -2492,6 +2529,35 @@ public sealed class GameWindow : IDisposable // are non-null here. The resolver lambda captures _dats and swallows // dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale. AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null; + var staticLiveRootCommitter = new StaticLiveRootCommitter( + () => _liveEntities, + _physicsEngine.ShadowObjects, + () => (_liveCenterX, _liveCenterY), + entity => _effectPoses.UpdateRoot(entity)); + _staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler( + capturedAnimLoader!, + CaptureAnimationHooks, + (entity, poses, availability) => + _effectPoses.Publish(entity, poses, availability), + entity => entity.ServerGuid == 0 + || (_liveEntities?.TryGetRecord( + entity.ServerGuid, + out LiveEntityRecord resident) == true + && resident.ProjectionKind is LiveEntityProjectionKind.World + && ReferenceEquals(resident.WorldEntity, entity) + && resident.IsSpatiallyProjected + && resident.IsSpatiallyVisible + && resident.FullCellId != 0), + (entity, body) => + _ = staticLiveRootCommitter.Commit(entity, body), + entity => entity.ServerGuid == 0 + ? 0UL + : _liveEntities?.TryGetRecord( + entity.ServerGuid, + out LiveEntityRecord resident) == true + && ReferenceEquals(resident.WorldEntity, entity) + ? resident.ProjectionMutationVersion + : ulong.MaxValue); AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e) { try @@ -2511,11 +2577,20 @@ public sealed class GameWindow : IDisposable IReadOnlyList parts = e.IndexedPartTransforms; IReadOnlyList availability = e.IndexedPartAvailable; var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup); + bool usesStaticAnimationWorkset = e.ServerGuid == 0 + || (_liveEntities?.TryGetRecord( + e.ServerGuid, + out LiveEntityRecord liveRecord) == true + && (liveRecord.FinalPhysicsState + & AcDream.Core.Physics.PhysicsStateFlags.Static) != 0); return new AcDream.App.Rendering.Vfx.ScriptActivationInfo( scriptId, parts, profile, - availability); + availability, + setup, + (uint)setup.DefaultAnimation, + usesStaticAnimationWorkset); } catch { @@ -2534,7 +2609,9 @@ public sealed class GameWindow : IDisposable entityEffects?.OnDatStaticEntityRemoved(ownerId); _lightingSink?.UnregisterOwner(ownerId); _translucencyFades.ClearEntity(ownerId); - }); + }, + (entity, info) => _staticAnimationScheduler.Register(entity, info), + ownerId => _staticAnimationScheduler.Unregister(ownerId)); _entityScriptActivator = entityScriptActivator; // Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock @@ -2586,7 +2663,8 @@ public sealed class GameWindow : IDisposable lock (_datLock) return _dats?.Get(setupId); }, - entity => _effectPoses.UpdateRoot(entity)) + entity => _effectPoses.UpdateRoot(entity), + () => (_liveCenterX, _liveCenterY)) { DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), }; @@ -2945,16 +3023,56 @@ public sealed class GameWindow : IDisposable AcDream.Core.Net.ObjectTableWiring.Wire( session, Objects, () => _playerServerGuid, LocalPlayer); AcDream.Core.Net.CombatStateWiring.Wire(session, Combat); - _liveSession.EntitySpawned += OnLiveEntitySpawned; - _liveSession.EntityDeleted += OnLiveEntityDeleted; - _liveSession.EntityPickedUp += OnLiveEntityPickedUp; - _liveSession.MotionUpdated += OnLiveMotionUpdated; - _liveSession.PositionUpdated += OnLivePositionUpdated; - _liveSession.VectorUpdated += OnLiveVectorUpdated; - _liveSession.StateUpdated += OnLiveStateUpdated; - _liveSession.ParentUpdated += OnLiveParentUpdated; - _liveSession.TeleportStarted += OnTeleportStarted; - _liveSession.AppearanceUpdated += OnLiveAppearanceUpdated; + _liveSession.EntitySpawned += spawn => + _inboundEntityEvents.Run( + this, + spawn, + static (window, value) => window.OnLiveEntitySpawned(value)); + _liveSession.EntityDeleted += deletion => + _inboundEntityEvents.Run( + this, + deletion, + static (window, value) => window.OnLiveEntityDeleted(value)); + _liveSession.EntityPickedUp += pickup => + _inboundEntityEvents.Run( + this, + pickup, + static (window, value) => window.OnLiveEntityPickedUp(value)); + _liveSession.MotionUpdated += motion => + _inboundEntityEvents.Run( + this, + motion, + static (window, value) => window.OnLiveMotionUpdated(value)); + _liveSession.PositionUpdated += position => + _inboundEntityEvents.Run( + this, + position, + static (window, value) => window.OnLivePositionUpdated(value)); + _liveSession.VectorUpdated += vector => + _inboundEntityEvents.Run( + this, + vector, + static (window, value) => window.OnLiveVectorUpdated(value)); + _liveSession.StateUpdated += state => + _inboundEntityEvents.Run( + this, + state, + static (window, value) => window.OnLiveStateUpdated(value)); + _liveSession.ParentUpdated += parent => + _inboundEntityEvents.Run( + this, + parent, + static (window, value) => window.OnLiveParentUpdated(value)); + _liveSession.TeleportStarted += teleport => + _inboundEntityEvents.Run( + this, + teleport, + static (window, value) => window.OnTeleportStarted(value)); + _liveSession.AppearanceUpdated += appearance => + _inboundEntityEvents.Run( + this, + appearance, + static (window, value) => window.OnLiveAppearanceUpdated(value)); // Phase 6c — PlayScript (0xF754) arrives from the server as // a (guid, scriptId) pair. Resolve the guid's current world @@ -2964,9 +3082,16 @@ public sealed class GameWindow : IDisposable // retail uses for spell casts, combat flinches, emote // gestures, AND — per Agent #5 research — lightning // flashes during stormy weather. - _liveSession.PlayPhysicsScriptReceived += OnPlayScriptReceived; - _liveSession.PlayPhysicsScriptTypeReceived += message => - _entityEffects?.HandleTyped(message); + _liveSession.PlayPhysicsScriptReceived += script => + _inboundEntityEvents.Run( + this, + script, + static (window, value) => window.OnPlayScriptReceived(value)); + _liveSession.PlayPhysicsScriptTypeReceived += message => + _inboundEntityEvents.Run( + this, + message, + static (window, value) => window._entityEffects?.HandleTyped(value)); // Phase 5d — AdminEnvirons (0xEA60): fog presets + sound // cues. Fog types (0x00..0x06) set WeatherSystem.Override; @@ -3663,6 +3788,8 @@ public sealed class GameWindow : IDisposable $"(guid=0x{spawn.Guid:X8})"); return; } + if (_liveEntities.TryGetRecord(spawn.Guid, out var setupRecord)) + setupRecord.HasPartArray = true; // Phase 6: resolve the entity's idle motion frame from its // MotionTable chain. For creatures and characters this gives us @@ -4334,14 +4461,86 @@ public sealed class GameWindow : IDisposable } } + // CPartArray::InitDefaults (0x00518980) installs Setup.DefaultAnimation + // for every setup-backed PartArray, even when there is no MotionTable + // and no independently resolved idle cycle. Physics-Static owners are + // advanced by RetailStaticAnimatingObjectScheduler; a non-static live + // owner remains in the ordinary CPhysics object workset. + bool isPhysicsStatic = _liveEntities.TryGetRecord( + spawn.Guid, + out LiveEntityRecord animationRecord) + && (animationRecord.FinalPhysicsState + & AcDream.Core.Physics.PhysicsStateFlags.Static) != 0; + AcDream.Core.Physics.IAnimationLoader? setupDefaultLoader = _animLoader; + if (!retainedAnimationRuntime + && !_animatedEntities.TryGetValue(entity.Id, out _) + && setupDefaultLoader is not null + && (uint)setup.DefaultAnimation != 0) + { + var sequencer = new AcDream.Core.Physics.AnimationSequencer( + setup, + new DatReaderWriter.DBObjs.MotionTable(), + setupDefaultLoader); + if (sequencer.HasCurrentNode) + { + _animatedEntities[entity.Id] = new AnimatedEntity + { + Entity = entity, + Setup = setup, + Animation = null!, + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = scale, + PartTemplate = animatedPartTemplate, + PartAvailability = indexedPartAvailable, + CurrFrame = 0, + Sequencer = sequencer, + }; + } + } + // MotionDone ownership is established with the logical animation // runtime, before EnterPlayerModeNow can dispatch SetPosition -> // StopCompletely. That call may synchronously finish a zero-tick // Ready entry; dropping its callback leaves CMotionInterp with an // unmatched node that permanently starves later MoveTo/TurnTo work. if (_animatedEntities.TryGetValue(entity.Id, out var registeredAnimation)) + { EnsureMotionDoneBinding(spawn.Guid, registeredAnimation); + if (isPhysicsStatic + && registeredAnimation.Sequencer is { } staticSequencer + && spawn.Position is { } staticPosition) + { + AcDream.Core.Physics.PhysicsBody staticBody = + _liveEntities.GetOrCreatePhysicsBody( + spawn.Guid, + incarnation => + { + var body = new AcDream.Core.Physics.PhysicsBody + { + Orientation = entity.Rotation, + }; + AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize( + body, + incarnation); + body.SnapToCell( + staticPosition.LandblockId, + entity.Position, + new System.Numerics.Vector3( + staticPosition.PositionX, + staticPosition.PositionY, + staticPosition.PositionZ)); + return body; + }); + _staticAnimationScheduler?.BindLiveOwner( + entity, + staticSequencer, + staticBody); + } + } + // Renderer, script owner, optional animation owner, collision body, // and effect profile are now all installed. Only at this boundary may // the mixed pre-Create F754/F755 FIFO replay against the local ID. @@ -4861,10 +5060,11 @@ public sealed class GameWindow : IDisposable uint cellId, bool force = false) { - if (_liveEntities?.IsHidden(_playerServerGuid) == true) + if (_liveEntities?.IsHidden(_playerServerGuid) == true + || cellId == 0 + || !IsCurrentVisibleLocalPlayerProjection(playerEntity)) { - _physicsEngine.ShadowObjects.Suspend(playerEntity.Id); - _lastLocalPlayerShadow = null; + SuspendLocalPlayerShadow(playerEntity); return; } @@ -4893,6 +5093,20 @@ public sealed class GameWindow : IDisposable cellId); } + private bool IsCurrentVisibleLocalPlayerProjection( + AcDream.Core.World.WorldEntity playerEntity) => + _liveEntities is { } runtime + && runtime.TryGetRecord(playerEntity.ServerGuid, out var record) + && ReferenceEquals(record.WorldEntity, playerEntity) + && runtime.IsCurrentSpatialRootObject(record); + + private void SuspendLocalPlayerShadow( + AcDream.Core.World.WorldEntity playerEntity) + { + _physicsEngine.ShadowObjects.Suspend(playerEntity.Id); + _lastLocalPlayerShadow = null; + } + /// /// #175: the motion table's default-state pose (the closed pose for /// doors) — the derivation lives in @@ -4926,8 +5140,8 @@ public sealed class GameWindow : IDisposable /// /// R3-W4: one-time per-remote wiring of the animation-dispatch stack — - /// the persistent (ObservedOmega turn - /// callbacks), Motion.DefaultSink (so + /// the persistent , + /// Motion.DefaultSink (so /// apply_current_movement's interpreted branch dispatches cycles /// — the retail mechanism behind the deleted K-fix18 forced-Falling), /// and the RemoveLinkAnimations/InitializeMotionTables @@ -4936,28 +5150,14 @@ public sealed class GameWindow : IDisposable /// the VectorUpdate path regardless of arrival order. /// private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings( - RemoteMotion rm, AnimatedEntity ae, uint serverGuid) + RemoteMotion rm, AnimatedEntity? ae, uint serverGuid) { - if (ae.Sequencer is null) - return rm.Sink; - if (rm.Sink is not null) - return rm.Sink; - - var rmForSink = rm; - rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer) + AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer; + if (sequencer is not null && rm.Sink is null) { - TurnApplied = (turnMotion, turnSpeed) => - { - float signed = (turnMotion & 0xFFu) == 0x0E - ? -System.MathF.Abs(turnSpeed) - : turnSpeed; - rmForSink.ObservedOmega = new System.Numerics.Vector3( - 0f, 0f, -(System.MathF.PI / 2f) * signed); - }, - TurnStopped = () => - rmForSink.ObservedOmega = System.Numerics.Vector3.Zero, - }; - rm.Motion.DefaultSink = rm.Sink; + 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 → @@ -4971,11 +5171,20 @@ public sealed class GameWindow : IDisposable // 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. - rm.Motion.RemoveLinkAnimations = () => ae.Sequencer.Manager.HandleEnterWorld(); - rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState(); - // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions - // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions). - rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions; + 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; // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness // wiring (the conformance-tested reference). Positions are WORLD @@ -4996,7 +5205,8 @@ public sealed class GameWindow : IDisposable // (own side) and StickyManager::adjust_offset's own-radius gap term. // Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with // R5-V3"). - var selfEntity = ae.Entity; + 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. @@ -5199,15 +5409,17 @@ public sealed class GameWindow : IDisposable /// table is flat (no client-side parenting), so the guid is used as-is — /// the same convention as RouteServerMoveTo's TopLevelId. /// - private void StickToObjectFromWire(EntityPhysicsHost? host, uint targetGuid) + private void StickToObjectFromWire( + AcDream.Core.Physics.Motion.IPhysicsObjHost? host, + uint targetGuid) { - if (host is null) + 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); - host.PositionManager.StickTo(targetGuid, radius, height); + entityHost.PositionManager.StickTo(targetGuid, radius, height); } /// @@ -5601,9 +5813,30 @@ public sealed class GameWindow : IDisposable if (!retainPayload) return; - if (_dats is null) 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)) 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 @@ -5789,185 +6022,71 @@ public sealed class GameWindow : IDisposable // 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; - _playerController.Motion.InterruptCurrentMovement?.Invoke(); - _playerController.Motion.UnstickFromObject?.Invoke(); - - // R5-V4a: unpack_movement HEAD style-on-change - // (0x00524440 @00524502-0052452c): wire style index → - // command word (command_ids[]; style-class indices map to - // 0x80000000|index — the funnel's S0-verified conversion) - // and `if (current style != wire style) DoMotion(style, - // ctor defaults)` BEFORE the movement-type switch — for - // EVERY type. acdream previously applied style only via - // the mt-0 funnel copy, so a chase/turn UM (mt 6-9) - // carrying a stance change started the move in the OLD - // stance (the RetailObserverTraceConformanceTests "S3 - // wires the unpack-level style-on-change" exclusion — - // this is that wiring). - uint wireStylePlayer = stance != 0 - ? (0x80000000u | (uint)stance) : 0x8000003Du; - if (_playerController.Motion.InterpretedState.CurrentStyle != wireStylePlayer) - _playerController.Motion.DoMotion(wireStylePlayer, - new AcDream.Core.Physics.Motion.MovementParameters()); - - if (_playerController.MoveTo is not null - && RouteServerMoveTo(_playerController.Movement, - _playerController.CellId, update)) + // 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}")); + $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}")); } return; } - - // Retail case-0 order: wholesale interpreted-state copy, - // then stick_to_object, then the unconditional - // standing_longjump flag write (@00524551-0052458e). - if (update.MotionState.MovementType == 0) - { - // Retail MovementManager::unpack_movement case 0 - // (0x00524440) applies the constructor-defaulted wire - // state wholesale for the local player too. This is - // how ACE's server-selected combat action reaches the - // player's motion table; autonomous movement echoes - // were already rejected by the gate above. - var playerIms = AcDream.App.Physics.InboundInterpretedMotionFactory.Create( - update.MotionState, - ae.Sequencer.CurrentMotion & 0xFF000000u); - _playerController.Motion.MoveToInterpretedState( - playerIms, _playerController.Motion.DefaultSink); - - if (update.MotionState.StickyObjectGuid is { } playerSticky - && playerSticky != 0) - StickToObjectFromWire(_playerHost, playerSticky); - _playerController.Motion.StandingLongJump = - update.MotionState.StandingLongJump; - } + if (!localDispatch.AppliedInterpretedState) + return; + fullMotion = localDispatch.CurrentForwardCommand; } } else { - // ── L.2g S2b (2026-07-02): remote entities flow through the - // verbatim CMotionInterp funnel. The wire state becomes an - // InboundInterpretedState (retail UnPack defaults for absent - // fields — a flags=0 UM is a wholesale stop, S0-verified), - // MotionInterpreter.MoveToInterpretedState applies it with - // retail's exact dispatch order + the 15-bit action-stamp - // gate (conformance: RetailObserverTraceConformanceTests, - // 183/183 vs the live retail-observer cdb trace), and the - // gate-passed dispatches go straight into the motion-table - // stack via MotionTableDispatchSink -> PerformMovement - // (R2-Q5; GetObjectSequence + is_allowed decide — no sink- - // side pick; airborne handling is the funnel's - // contact_allows_move gate, the retail mechanism behind the - // old K-fix17 guard). - // - // R4-V4: retail unpack_movement dispatch (0x00524440) — the - // head-interrupt fires for EVERY movement type, then types - // 6/7/8/9 route to MoveToManager.PerformMovement (which - // cancels again itself — retail does both) while ONLY type 0 - // flows through the interpreted-state funnel below. - // - // R4-V5 door fix (2026-07-03 user report: doors stopped - // animating): entities that never receive an UpdatePosition - // (doors, levers — static animated objects) never got a - // RemoteMotion (created only in the UP handler), so since - // the L.2g S2b funnel cutover NOTHING applied their UM - // forward-command (the old direct SetCycle became the - // rm-only funnel) — a used door flipped ETHEREAL but never - // played its On/Off cycle. Retail runs the SAME unpack → - // CMotionInterp pipeline for every entity class; create the - // rm on first UM exactly like the UP handler does. - if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) - { - remoteMot = CreateRemoteMotion(update.Guid); - remoteMot.Body.Orientation = entity.Rotation; - remoteMot.Body.Position = entity.Position; - _remoteDeadReckon[update.Guid] = remoteMot; - } - { - var sink = EnsureRemoteMotionBindings(remoteMot, ae, update.Guid); - - // unpack_movement head (@300566): interrupt + unstick. - remoteMot.Motion.InterruptCurrentMovement?.Invoke(); - remoteMot.Motion.UnstickFromObject?.Invoke(); - - // R5-V4a: unpack_movement head style-on-change — see the - // player-side comment (@00524502-0052452c). Runs BEFORE - // the type routing for every movement type; the mt-0 - // funnel below still performs its own full style - // adoption (retail has BOTH — the head fires on CHANGE - // only, so an unchanged stance is a no-op here). - uint wireStyleRemote = stance != 0 - ? (0x80000000u | (uint)stance) : 0x8000003Du; - if (remoteMot.Motion.InterpretedState.CurrentStyle != wireStyleRemote) - remoteMot.Motion.DoMotion(wireStyleRemote, - new AcDream.Core.Physics.Motion.MovementParameters()); - - // R4-V5: the type-6..9 routing body is shared with the - // local player (RouteServerMoveTo) — behavior identical - // to the R4-V4 inline blocks it was extracted from. - // (MoveTo null = EnsureRemoteMotionBindings early-outed - // on a sequencer-less entity — same skip as pre-V5.) - if (remoteMot.MoveTo is not null - && RouteServerMoveTo(remoteMot.Movement, - remoteMot.CellId, update)) - { - return; - } - - // [FWD_WIRE] + observed-velocity history invalidation on - // a forward-command change (pre-S2 behavior, unchanged: - // the per-tick scaling must not reuse a stale ratio - // derived from the OLD motion). - if (remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion) - { - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - System.Console.WriteLine( - $"[FWD_WIRE] guid={update.Guid:X8} " - + $"oldCmd=0x{remoteMot.Motion.InterpretedState.ForwardCommand:X8} " - + $"newCmd=0x{fullMotion:X8} " - + $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}"); - } - remoteMot.PrevServerPosTime = 0.0; - } - - // Build the constructor-defaulted wholesale state through - // the SAME converter used by the local-player path. - var ims = AcDream.App.Physics.InboundInterpretedMotionFactory.Create( - update.MotionState, - ae.Sequencer.CurrentMotion & 0xFF000000u); - if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0) - { - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - System.Console.WriteLine( - $"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} " - + $"turnFull=0x{ims.TurnCommand:X8} speed={ims.TurnSpeed:F3}"); - } - } - - // R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the - // motion-table stack (GetObjectSequence + is_allowed - // decide) — mt-0 only post-V4 (types 6-9 returned above). - remoteMot.Motion.MoveToInterpretedState(ims, sink); - - // R5-V4: retail unpack_movement case-0 TAIL order - // (@00524583-0052458e): move_to_interpreted_state FIRST - // (above), THEN stick_to_object when the motionFlags 0x1 - // trailer carried a guid, THEN standing_longjump ← - // motionFlags 0x2 (UNCONDITIONAL — an absent flag - // CLEARS it). - if (update.MotionState.StickyObjectGuid is { } stickyGuid - && stickyGuid != 0) - StickToObjectFromWire(remoteMot.Host, stickyGuid); - remoteMot.Motion.StandingLongJump = - update.MotionState.StandingLongJump; - } + // 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. @@ -5975,6 +6094,15 @@ public sealed class GameWindow : IDisposable // 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 _remoteLastMove timestamp to "now". Without this, @@ -6031,6 +6159,97 @@ public sealed class GameWindow : IDisposable 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, + AnimatedEntity? 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 @@ -6044,27 +6263,57 @@ public sealed class GameWindow : IDisposable /// 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 contributes the already-owned CPhysicsObj body - /// and canonical live-record cell; an ordinary remote gets local storage. + /// A retained projectile or static-animation owner contributes the + /// already-owned CPhysicsObj body; an ordinary remote gets local storage. /// private RemoteMotion CreateRemoteMotion(uint serverGuid) { - if (_projectileController?.TryGetBody(serverGuid, out var projectileBody) != true) + LiveEntityRecord? record = null; + if (_liveEntities is { } liveEntities + && liveEntities.TryGetRecord(serverGuid, out var currentRecord)) { - var remote = new RemoteMotion(); - if (_liveEntities?.TryGetRecord(serverGuid, out var record) == true) + 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); - return remote; } - // Retail has one CPhysicsObj. When a MovementManager is added after a - // missile body already exists, share both its body and its canonical - // live-record cell rather than introducing a second cell owner. - return new RemoteMotion(projectileBody); + RemoteMotion incarnation = remote; + remote.Movement.ActivatePhysicsObject = () => + _liveEntities?.TryActivateOrdinaryObject(serverGuid, incarnation); + return remote; } /// @@ -6079,28 +6328,63 @@ public sealed class GameWindow : IDisposable 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( - update.Guid, + 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². - rm.Body.set_velocity(update.Velocity); - - // L.3.1 Task 6: apply Omega too. Was parsed but ignored, leaving - // remote jumping/turning arcs flat. Mirrors retail SmartBox:: - // DoVectorUpdate (acclient @ 0x004521C0) which calls both - // CPhysicsObj::set_velocity AND CPhysicsObj::set_omega. - rm.Body.Omega = update.Omega; + // 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 @@ -6133,8 +6417,21 @@ public sealed class GameWindow : IDisposable { EnsureRemoteMotionBindings(rm, ae, update.Guid); rm.Motion.LeaveGround(); - rm.Body.set_velocity(update.Velocity); - rm.Body.Omega = update.Omega; + if (!_liveEntities.IsCurrentVectorAuthority( + remoteRecord, + acceptedVectorAuthorityVersion) + || !_liveEntities.IsCurrentVelocityAuthority( + remoteRecord, + acceptedVectorVelocityAuthorityVersion) + || !_liveEntities.TryCommitAuthoritativeVector( + remoteRecord, + rm.Body, + update.Velocity, + update.Omega, + _physicsScriptGameTime)) + { + return; + } } } @@ -6159,6 +6456,7 @@ public sealed class GameWindow : IDisposable 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; @@ -6166,12 +6464,26 @@ public sealed class GameWindow : IDisposable _liveEntityLights?.OnStateChanged(parsed.Guid); _liveEntityPresentation?.OnStateAccepted(parsed.Guid); + if (!_liveEntities.IsCurrentStateAuthority( + record, + acceptedStateAuthorityVersion)) + { + return; + } + _projectileController?.ApplyAuthoritativeState( - parsed.Guid, + record, + acceptedStateAuthorityVersion, record.FinalPhysicsState, _physicsScriptGameTime, _liveCenterX, _liveCenterY); + if (!_liveEntities.IsCurrentStateAuthority( + record, + acceptedStateAuthorityVersion)) + { + return; + } if (parsed.Guid == _playerServerGuid) _playerController?.ApplyPhysicsState(record.FinalPhysicsState); @@ -6275,7 +6587,7 @@ public sealed class GameWindow : IDisposable AcDream.Core.Physics.Position canonical = _playerController.CellPosition; uint cellId = canonical.ObjCellId; System.Numerics.Vector3 position = canonical.Frame.Origin; - System.Numerics.Quaternion rotation = YawToAcQuaternion(_playerController.Yaw); + System.Numerics.Quaternion rotation = _playerController.BodyOrientation; if (!AcDream.Core.Physics.PositionFrameValidation.IsValid( cellId, position, rotation)) { @@ -6311,7 +6623,7 @@ public sealed class GameWindow : IDisposable update, isLocalPlayer: update.Guid == _playerServerGuid, forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null - ? YawToAcQuaternion(_playerController.Yaw) + ? _playerController.BodyOrientation : null, currentLocalVelocity: update.Guid == _playerServerGuid && _playerController is not null ? _playerController.BodyVelocity @@ -6333,6 +6645,27 @@ public sealed class GameWindow : IDisposable 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 @@ -6341,6 +6674,8 @@ public sealed class GameWindow : IDisposable // below is a separate concern and may never be needed for UI-only state. lock (_datLock) TryInitializeLiveCenter(acceptedSpawn); + if (!IsCurrentPositionOwner()) + return; var p = update.Position; int lbX = (int)((p.LandblockId >> 24) & 0xFFu); @@ -6356,23 +6691,37 @@ public sealed class GameWindow : IDisposable && _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; _equippedChildRenderer?.OnChildBecameUnparented(update.Guid); + if (!IsCurrentPositionOwner()) + return; lock (_datLock) OnLiveEntitySpawnedLocked( acceptedSpawn, LiveProjectionPurpose.SpatialRecovery); + 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 @@ -6413,31 +6762,48 @@ public sealed class GameWindow : IDisposable && (remoteHardTeleport || _remoteTeleportController?.HasPending(update.Guid) == true); if (remoteHardTeleport) - RunRemoteTeleportHook(update.Guid, entity.Id); + { + 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( - update.Guid, + 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 (remotePlacementRequired) - { - _remoteTeleportController!.BeginPlacement( + if (!_liveEntities.TryGetRecord( update.Guid, - acceptedSpawn.InstanceSequence); + 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 @@ -6449,7 +6815,30 @@ public sealed class GameWindow : IDisposable entity.SetPosition(worldPos); entity.ParentCellId = p.LandblockId; entity.Rotation = rot; - _liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId); + 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 @@ -6464,7 +6853,7 @@ public sealed class GameWindow : IDisposable // 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, movement-gated) and the player UP-branch tail + // 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. @@ -6523,6 +6912,30 @@ public sealed class GameWindow : IDisposable 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 @@ -6539,6 +6952,9 @@ public sealed class GameWindow : IDisposable out LiveEntityRecord teleportRecord) && teleportRecord.IsSpatiallyVisible; var placement = _remoteTeleportController!.TryApply( + positionRecord, + acceptedPositionAuthorityVersion, + acceptedPositionVelocityAuthorityVersion, rmState, entity, worldPos, @@ -6552,6 +6968,11 @@ public sealed class GameWindow : IDisposable projectionVisible, acceptedSpawn.InstanceSequence, acceptedSpawn.PositionSequence); + if (placement.Superseded + || !IsCurrentPositionOwner(entity)) + { + return; + } if (!placement.Applied) { entity.SetPosition(rmState.Body.Position); @@ -6562,6 +6983,8 @@ public sealed class GameWindow : IDisposable return; } + if (!IsCurrentPositionOwner(entity)) + return; entity.SetPosition(rmState.Body.Position); entity.Rotation = rmState.Body.Orientation; return; @@ -6577,10 +7000,9 @@ public sealed class GameWindow : IDisposable // if (IsPlayerGuid(update.Guid)) { - // Orientation always snaps on receipt — InterpolationManager walks - // position only; heading would otherwise lag the queue. - rmState.Body.Orientation = rot; - + // 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 @@ -6650,6 +7072,7 @@ public sealed class GameWindow : IDisposable | 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 @@ -6666,10 +7089,25 @@ public sealed class GameWindow : IDisposable { 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`). - rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + if (_liveEntities.IsCurrentStateAuthority( + positionRecord, + landingStateAuthorityVersion)) + { + rmState.Body.State &= + ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + } return; } @@ -6693,9 +7131,9 @@ public sealed class GameWindow : IDisposable // enqueue for the smooth catch-up. float bodyToTarget = System.Numerics.Vector3.Distance( rmState.Body.Position, worldPos); - bool willBeDrTicked = - _animatedEntities.TryGetValue(entity.Id, out var aeDrPl) - && aeDrPl.Sequencer is not null; + bool willBeDrTicked = WillAdvanceRemoteMotion( + update.Guid, + rmState); if (dist > MaxPhysicsDistance || !willBeDrTicked || bodyToTarget > BodySnapThreshold) @@ -6704,6 +7142,7 @@ public sealed class GameWindow : IDisposable // SetPositionSimple slide-snap. Clear queue. rmState.Interp.Clear(); rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; } else { @@ -6713,12 +7152,15 @@ public sealed class GameWindow : IDisposable // 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. - float headingFromQuat = ExtractYawFromQuaternion(rot); - rmState.Interp.Enqueue( + System.Numerics.Quaternion? immediateOrientation = + rmState.Interp.Enqueue( worldPos, - headingFromQuat, - isMovingTo: false, - currentBodyPosition: rmState.Body.Position); + 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 @@ -6754,11 +7196,22 @@ public sealed class GameWindow : IDisposable // 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 (movement-gated). - if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid)) - _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, rmState, _liveCenterX, _liveCenterY); + // 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; } @@ -6828,6 +7281,7 @@ public sealed class GameWindow : IDisposable if (rmState.Airborne) { rmState.Body.Position = worldPos; + rmState.Body.Orientation = rot; } else { @@ -6846,12 +7300,13 @@ public sealed class GameWindow : IDisposable // 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 per-tick DR loop will CONSUME the queue (a - // non-null Sequencer, the TickAnimations gate); else nothing walks - // the body to the waypoint and it would freeze at its last pos. - bool willBeDrTickedNpc = - _animatedEntities.TryGetValue(entity.Id, out var aeDrNpc) - && aeDrNpc.Sequencer is not null; + // 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 AnimatedEntity exists. + bool willBeDrTickedNpc = WillAdvanceRemoteMotion( + update.Guid, + rmState); if (firstUpNpc || !willBeDrTickedNpc || distNpc > MaxPhysicsDistanceNpc @@ -6860,15 +7315,21 @@ public sealed class GameWindow : IDisposable // 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). - float headingNpc = ExtractYawFromQuaternion(rot); - rmState.Interp.Enqueue( - worldPos, headingNpc, isMovingTo: false, - currentBodyPosition: rmState.Body.Position); + 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; } } } @@ -6899,58 +7360,13 @@ public sealed class GameWindow : IDisposable // rmState.CellId so the NEXT frame starts in the correct cell. rmState.CellId = p.LandblockId; - // Retail hard-snaps orientation on UpdatePosition (set_frame, - // FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment). - // Rotation rate between UPs comes from the formula-based - // omega seed on UpdateMotion (π/2 × turnSpeed). We tried - // deriving omega from UP deltas, but the first UP after a - // turn starts incorporates the pre-turn interval and produces - // a halved "observed" rate → visible slow-start. Formula-only - // is stable and simple; hard-snap fixes any drift. - // R5-V3 #171 residual: gated on the stick (see the position-snap - // comment above) — ACE's server-side facing lags the strafing - // target; stomping it over the sticky's per-tick face-tracking - // was the "monster attacking while facing a different direction" - // gate report. - if (!snapSuppressedByStick) - rmState.Body.Orientation = rot; + // 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; - // Align the body's physics clock with our clock so update_object - // doesn't sub-step a huge initial gap. - rmState.Body.LastUpdateTime = rmState.LastServerPosTime; - - // ACE broadcasts UpdatePosition WITHOUT HasVelocity for player - // remote motion — even while actively running. Per packet - // captures: UPs always arrive with velocity null. So we can't - // use UP-absent-velocity as a stop signal (was previously a - // bug that fired StopCompletely every UP → intermittent run). - // - // Stop is signaled by UpdateMotion(ForwardCommand = Ready = - // 0x41000003), handled in OnLiveMotionUpdated. UP's role here - // is just to hard-snap position and adopt velocity IF the - // packet happens to carry one (rare for players, common for - // scripted-path NPCs / missiles). - if (update.Velocity is { } svel) - { - rmState.Body.Velocity = svel; - // Position updates carry physics velocity only. Retail - // CPhysicsObj::MoveOrTeleport never derives an animation - // command from it; UpdateMotion owns the interpreted state. - // In particular, a dead creature legitimately broadcasts - // zero velocity while its Dead cycle must remain persistent. - } - else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity - && !snapSuppressedByStick && rmState.Airborne) - { - // AIRBORNE-only (#184): a grounded NPC translates by the interp catch-up, - // and the per-tick grounded loop zeroes Body.Velocity before integrating — - // so this write is DEAD for grounded remotes and only muddies the - // "ServerVelocity is diagnostic-only" contract. Gating it to airborne keeps - // the arc's velocity re-sync and prevents synth velocity from leaking into a - // grounded integrate. - rmState.Body.Velocity = rmState.ServerVelocity; - } if (rmState.HasServerVelocity && !snapSuppressedByStick @@ -6984,13 +7400,23 @@ public sealed class GameWindow : IDisposable // 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, movement-gated. rmState.CellId is the server cell adopted above. - if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid)) - _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, rmState, _liveCenterX, _liveCenterY); - + // 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 @@ -7237,7 +7663,7 @@ public sealed class GameWindow : IDisposable // direction). The render entity already got _pendingTeleportRot above; sync the // controller yaw so the camera + movement frame match it instead of the stale // pre-teleport facing. - _playerController.Yaw = AcQuaternionToYaw(_pendingTeleportRot); + _playerController.SetBodyOrientation(_pendingTeleportRot); _chaseCamera?.Update(snappedPos, _playerController.Yaw); // SmartBox::PlayerPositionUpdated (0x00453870) calls @@ -9558,64 +9984,6 @@ public sealed class GameWindow : IDisposable ? System.FormattableString.Invariant($"0x{command.Value:X8}") : "-"; - /// - /// Convert our internal yaw (math convention: 0=+X East, PI/2=+Y North) - /// to AC's quaternion heading convention. - /// AC heading: 0=West, 90=North, 180=East, 270=South. - /// Formula from holtburger Quaternion::from_heading. - /// - private static System.Numerics.Quaternion YawToAcQuaternion(float yaw) - { - // Our yaw → AC heading in degrees: - // yaw=0 → East → AC 180°, yaw=PI/2 → North → AC 90° - // heading_deg = 180 - yaw_degrees - float yawDeg = yaw * (180f / MathF.PI); - float headingDeg = 180f - yawDeg; - if (headingDeg < 0f) headingDeg += 360f; - if (headingDeg >= 360f) headingDeg -= 360f; - - // holtburger from_heading: theta = (450 - heading_deg) in radians - float theta = (450f - headingDeg) * (MathF.PI / 180f); - float halfTheta = theta * 0.5f; - float w = MathF.Cos(halfTheta); - float z = MathF.Sin(halfTheta); - - // Canonicalize: w must be non-negative - if (w < 0f) { w = -w; z = -z; } - - return new System.Numerics.Quaternion(0f, 0f, z, w); - } - - /// - /// Inverse of : extracts the local yaw (rotation - /// about the Z axis, in radians) from an AC wire quaternion. - /// Yaw=0 faces +X (East). Used by the L.3.1 InterpolationManager routing to - /// convert server orientation into the heading expected by InterpolationManager.Enqueue. - /// Standard formula: atan2( 2(wz + xy), 1 − 2(y² + z²) ). - /// - private static float ExtractYawFromQuaternion(System.Numerics.Quaternion q) - { - return MathF.Atan2( - 2f * (q.W * q.Z + q.X * q.Y), - 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); - } - - /// - /// Exact inverse of : AC wire orientation → our internal - /// yaw (radians, 0=+X East). NOT the same as , which - /// returns the raw quaternion Z-angle (AC's theta = 450 - heading) — that carries a - /// 270° offset vs our yaw. Inverting the documented chain: theta = 450 - heading and - /// heading = 180 - yaw ⇒ yaw = thetaDeg - 270. Used to face the player the server-specified - /// way on a teleport arrival (retail drops you facing the portal's destination heading). - /// - private static float AcQuaternionToYaw(System.Numerics.Quaternion q) - { - float thetaDeg = ExtractYawFromQuaternion(q) * (180f / MathF.PI); - float yawDeg = thetaDeg - 270f; // 180 - (450 - thetaDeg) - float yaw = yawDeg * (MathF.PI / 180f); - return MathF.Atan2(MathF.Sin(yaw), MathF.Cos(yaw)); // normalize to (-PI, PI] - } - private void OnCameraModeChanged(bool _modeBool) { if (_mouseLook?.Active == true @@ -10064,9 +10432,8 @@ public sealed class GameWindow : IDisposable // re-classification is ever measured, gate on "entity is at its // captured rest state" (default motion AND no active fade), never on // "is mid-cycle". - _animatedIdsScratch.Clear(); - foreach (var k in _animatedEntities.Keys) - _animatedIdsScratch.Add(k); + _animatedEntities.CopySpatialIdsTo(_animatedIdsScratch); + _staticAnimationScheduler?.CopyAnimatedEntityIdsTo(_animatedIdsScratch); if (_equippedChildRenderer is not null) { foreach (uint id in _equippedChildRenderer.AttachedEntityIds) @@ -10964,20 +11331,24 @@ public sealed class GameWindow : IDisposable } } - private void RunRemoteTeleportHook(uint serverGuid, uint localEntityId) + private bool RunRemoteTeleportHook( + uint serverGuid, + uint localEntityId, + Func isCurrent) { _remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote); EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered) ? registered as EntityPhysicsHost : remote?.Host; - AcDream.App.Physics.RemoteTeleportHook.Execute( + 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))); + ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)), + isCurrent); } /// @@ -10985,7 +11356,14 @@ public sealed class GameWindow : IDisposable /// dispatch. Final root/part poses and their animation hooks are published /// together so rendering consumes one coherent update snapshot. /// - private void AdvanceLiveObjectRuntime(float dt) + private void AdvanceLiveObjectRuntime(float dt) => + _inboundEntityEvents.Run( + this, + dt, + static (window, deltaTime) => + window.AdvanceLiveObjectRuntimeCore(deltaTime)); + + private void AdvanceLiveObjectRuntimeCore(float dt) { // The local body participates in the same retail CPhysics phase as // remote objects. Its outbound movement snapshot is completed here, @@ -10998,26 +11376,34 @@ public sealed class GameWindow : IDisposable // the client walking to a corpse whose server callback was cancelled. _outboundInteractions.Drain(); _scriptRunner?.PublishTime(_physicsScriptGameTime); - if (_liveEntities is { } liveEntities) - { - _remotePhysicsUpdater.TickHiddenEntities( - liveEntities, + System.Numerics.Vector3? playerPosition = + _entitiesByServerGuid.TryGetValue( _playerServerGuid, - dt, - entity => _effectPoses.UpdateRoot(entity)); - } - - _projectileController?.Tick( - _physicsScriptGameTime, + out var ordinaryObjectViewer) + ? ordinaryObjectViewer.Position + : null; + IReadOnlyDictionary animationSchedules = + _liveAnimationScheduler.Tick( + dt, + playerPosition, + _localPlayerFrame.HiddenPartPoseDirty, _liveCenterX, _liveCenterY, - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer) - ? projectileViewer.Position - : null); + EnsureMotionDoneBinding); + // CPhysics::UseTime (0x00509950) processes the ordinary object table + // first, then its distinct static_animating_objects workset. + _staticAnimationScheduler?.Tick(dt); if (_animatedEntities.Count > 0) - TickAnimations(dt); + TickAnimations(animationSchedules); _equippedChildRenderer?.Tick(); + // CPhysicsObj::animate_static_object (0x00513DF0) reaches + // process_hooks only after UpdatePartsInternal and + // UpdateChildrenInternal. Ordinary objects complete AnimationDone in + // their earlier UpdatePositionInternal slot; the static workset has + // this distinct tail. + _staticAnimationScheduler?.ProcessHooks(); + // Advance already-active FP hooks before routing hooks emitted by // this PartArray update. A newly created translucency hook begins at // its authored Start value this frame; it does not consume dt twice. @@ -11070,15 +11456,20 @@ public sealed class GameWindow : IDisposable /// entities (no AnimatedEntity record) are untouched. The static /// renderer reads the new MeshRefs on the next Draw call. /// - private System.Numerics.Vector3 AdvanceLocalPlayerAnimationRoot(float dt) + private void AdvanceLocalPlayerAnimationRoot( + float dt, + AcDream.Core.Physics.Motion.MotionDeltaFrame output) { + ArgumentNullException.ThrowIfNull(output); + output.Reset(); + if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) || !_animatedEntities.TryGetValue(entity.Id, out var ae) || ae.Sequencer is not { } sequencer || _liveEntities?.ShouldAdvanceRootRuntime(_playerServerGuid) == false || _liveEntities?.IsHidden(_playerServerGuid) == true) { - return System.Numerics.Vector3.Zero; + return; } EnsureMotionDoneBinding(_playerServerGuid, ae); @@ -11089,13 +11480,27 @@ public sealed class GameWindow : IDisposable ae.PreparedSequenceFrames = sequencer.Advance(dt, rootFrame); ae.SequenceAdvancedBeforeAnimationPass = true; - // TickAnimations will publish this advance's final part transforms - // before the deferred hook frame is drained. - _animationHookFrames?.Capture(ae.Entity.Id, sequencer); - return rootFrame.Origin; + output.Origin = rootFrame.Origin; + output.Orientation = rootFrame.Orientation; } - private void TickAnimations(float dt) + private void CaptureLocalPlayerAnimationHooks() + { + if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) + && _animatedEntities.TryGetValue(entity.Id, out var ae) + && ae.Sequencer is { } sequencer) + { + CaptureAnimationHooks(ae.Entity.Id, sequencer); + } + } + + private void CaptureAnimationHooks( + uint ownerLocalId, + AcDream.Core.Physics.AnimationSequencer sequencer) => + _animationHookFrames?.Capture(ownerLocalId, sequencer); + + private void TickAnimations( + IReadOnlyDictionary animationSchedules) { // Retail has NO stop-detection heuristic — it relies on the // server sending explicit UpdateMotion(Ready). The server-side @@ -11106,18 +11511,9 @@ public sealed class GameWindow : IDisposable // UpdatePosition updates physics pose/velocity and never selects an // animation. Anything else is server buggery (packet loss, ACE bug) // — don't guess client-side. - var now = System.DateTime.UtcNow; - foreach (var kv in _animatedEntities) { var ae = kv.Value; - bool sequenceAdvancedBeforeAnimationPass = - ae.SequenceAdvancedBeforeAnimationPass; - bool managerTailHandledForObject = ae.Entity.ServerGuid == _playerServerGuid; - IReadOnlyList? preparedSequenceFrames = - ae.PreparedSequenceFrames; - ae.SequenceAdvancedBeforeAnimationPass = false; - ae.PreparedSequenceFrames = null; // The server guid is carried on the entity itself // (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at @@ -11137,79 +11533,22 @@ public sealed class GameWindow : IDisposable // Sequencer.Advance: no completion may precede its consumer. EnsureMotionDoneBinding(serverGuid, ae); - // Retail CPhysicsObj::UpdateObjectInternal skips root movement, - // MovementManager use, and PartArray animation while cell == 0. - // Pickup/parent leave-world preserves the same owners (including - // PhysicsScript/particles, ticked elsewhere) but must not keep the - // withdrawn body animating in its former cell. - if (serverGuid != 0 - && _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false) - continue; - - // Retail Hidden suppresses PartArray TIME ADVANCE, not part-pose - // composition. set_hidden -> HandleEnterWorld can move CSequence's - // cursor from a finished link/action to the first cyclic node; the - // next hidden CPhysicsObj::UpdateObjectInternal (0x005156B0) - // performs UpdatePositionInternal and then reaches set_frame -> - // CPartArray::UpdateParts, publishing that newly-current pose before - // the zero-time Hidden PES creates its silhouette particles. - bool hidden = serverGuid != 0 - && _liveEntities?.IsHidden(serverGuid) == true; - - // CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the - // CPartArray FIRST into a local root-motion Frame, then lets the - // PositionManager replace that Frame with interpolation catch-up. - // Advance remote sequences here so the physics owner consumes the - // literal CSequence delta; reconstructing a velocity from the - // interpreted Walk/Run command made bodies run past the queue head - // and snap backward on the next server position. - IReadOnlyList? seqFrames = - sequenceAdvancedBeforeAnimationPass ? preparedSequenceFrames : null; - bool sequencerAdvancedForObject = sequenceAdvancedBeforeAnimationPass; - if (!hidden - && ae.Sequencer is not null - && !sequencerAdvancedForObject - && serverGuid != 0 - && serverGuid != _playerServerGuid - && _remoteDeadReckon.TryGetValue(serverGuid, out var rm) - && _projectileController?.HandlesMovement(serverGuid) != true - // R4-V5 door fix companion: rm entries now also exist for - // static animated objects (doors/levers — created on first - // UM so the funnel can play their On/Off cycles). Those - // must NOT enter this dead-reckoning block: it force- - // asserts ground contact, zeroes/integrates velocity, and - // ground-resolves the body — position machinery for - // entities the server MOVES. "Has ever received an - // UpdatePosition" is exactly that distinction (there is - // nothing to dead-reckon between UPs that don't exist); - // their animation still plays via the sequencer's own - // TickAnimations advance. - && rm.LastServerPosTime > 0) + if (_liveEntities?.TryGetRecord( + serverGuid, + out LiveEntityRecord liveRecord) != true) { - var remoteRootMotionFrame = new DatReaderWriter.Types.Frame - { - Origin = System.Numerics.Vector3.Zero, - Orientation = System.Numerics.Quaternion.Identity, - }; - seqFrames = ae.Sequencer.Advance(dt, remoteRootMotionFrame); - _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer); - sequencerAdvancedForObject = true; - if (dt > 0f) - { - float rootMotionSpeed = remoteRootMotionFrame.Origin.Length() - * ae.Scale / dt; - rm.MaxRootMotionSpeedSinceLastUP = MathF.Max( - rm.MaxRootMotionSpeedSinceLastUP, - rootMotionSpeed); - } - _remotePhysicsUpdater.Tick( - rm, - ae, - dt, - remoteRootMotionFrame.Origin, - _liveCenterX, - _liveCenterY); - managerTailHandledForObject = true; + continue; + } + animationSchedules.TryGetValue(kv.Key, out LiveEntityAnimationSchedule schedule); + IReadOnlyList? seqFrames = + schedule.SequenceFrames; + bool composeParts = schedule.ComposeParts; + if (_staticAnimationScheduler?.TryTakeLivePartFrames( + kv.Key, + out var staticPartFrames) == true) + { + seqFrames = staticPartFrames; + composeParts = true; } // ── Get per-part (origin, orientation) from either sequencer or legacy ── @@ -11255,32 +11594,17 @@ public sealed class GameWindow : IDisposable rmDiag.LastSeqStateLogTime = nowSec; } } - if (!sequencerAdvancedForObject) - { - seqFrames = hidden - ? ae.Sequencer.SampleCurrentPose() - : ae.Sequencer.Advance(dt); - - // Capture hooks now, but deliver them only after every final - // part transform and equipped-child root has been published. - // This matches CPhysicsObj::UpdateObjectInternal followed by - // CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect - // reads this frame's pose, never the previous frame's pose. - // AnimationDone is processed semantically by Capture at - // retail's process_hooks slot. Pose-dependent delivery is - // deferred; the later manager-tail block owns UseTime. - if (!hidden) - _animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer); - } } else { // Legacy path (entities without a MotionTable / sequencer). int span = ae.HighFrame - ae.LowFrame; - if (span <= 0 && !hidden) continue; - if (!hidden) + bool hiddenLegacy = (liveRecord.FinalPhysicsState + & AcDream.Core.Physics.PhysicsStateFlags.Hidden) != 0; + if (span <= 0 && !hiddenLegacy) continue; + if (span > 0 && schedule.LegacyAdvanceSeconds > 0f) { - ae.CurrFrame += dt * ae.Framerate; + ae.CurrFrame += schedule.LegacyAdvanceSeconds * ae.Framerate; if (ae.CurrFrame > ae.HighFrame) { float over = ae.CurrFrame - ae.LowFrame; @@ -11291,41 +11615,6 @@ public sealed class GameWindow : IDisposable } } - // R6: objects which did not enter the local or moving-remote - // physics owner still need their CPartArray manager tail. Doors - // and levers commonly receive UpdateMotion without ever receiving - // UpdatePosition; classifying the tail by LastServerPosTime made - // their zero-tick completions depend on the old global hook drain. - // Hidden remotes were already handled by TickHiddenEntities above. - bool hiddenRemoteManagerTailHandled = hidden - && _remoteDeadReckon.TryGetValue(serverGuid, out _); - AcDream.Core.Physics.AnimationSequencer? tailSequencer = ae.Sequencer; - if (!managerTailHandledForObject - && !hiddenRemoteManagerTailHandled - && tailSequencer is not null) - { - if (_remoteDeadReckon.TryGetValue(serverGuid, out var tailRemote) - && _projectileController?.HandlesMovement(serverGuid) != true) - { - System.Action? handleTargeting = tailRemote.Host is { } targetHost - ? targetHost.HandleTargetting - : null; - System.Action? positionUseTime = tailRemote.Host is { } positionHost - ? positionHost.PositionManager.UseTime - : null; - AcDream.Core.Physics.RetailObjectManagerTail.Run( - checkDetection: null, - handleTargeting, - movementUseTime: tailRemote.Movement.UseTime, - partArrayHandleMovement: tailSequencer.Manager.UseTime, - positionUseTime); - } - else - { - tailSequencer.Manager.UseTime(); - } - } - int partCount = ae.PartTemplate.Count; // D5 (Commit A 2026-05-03): PARTSDIAG — proves whether @@ -11382,6 +11671,9 @@ public sealed class GameWindow : IDisposable } } + if (!composeParts) + continue; + // MP-Alloc (2026-07-05): reuse the entity's cached MeshRefs list // instead of allocating a fresh List(partCount) every // tick (see AnimatedEntity.MeshRefsScratch for the safety @@ -14406,17 +14698,25 @@ public sealed class GameWindow : IDisposable return false; } - _playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine); - if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord playerRecord) == true) + LiveEntityRecord? playerRecord = null; + if (_liveEntities?.TryGetRecord( + _playerServerGuid, + out LiveEntityRecord foundPlayerRecord) == true) + { + playerRecord = foundPlayerRecord; + } + _playerController = new AcDream.App.Input.PlayerMovementController( + _physicsEngine, + playerRecord?.ObjectClock); + if (playerRecord is not null) _playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState); // R4-V5: the local player's verbatim MoveToManager — same seam // wiring shape as EnsureRemoteMotionBindings, with three - // player-specific differences: (a) heading reads/writes go through - // the controller's Yaw (the authoritative facing the body - // quaternion is re-derived from every Update; a quaternion-only - // set_heading would be overwritten next frame) via the P5-pinned - // yaw↔heading bridge; (b) the contact seam reads the REAL Contact + // player-specific differences: (a) heading reads/writes use the + // controller's Yaw projection as the explicit Frame::get_heading / + // set_heading seam, while ordinary object ticks retain the complete + // authoritative body quaternion; (b) the contact seam reads the REAL Contact // transient bit (retail UseTime gates transient_state & 1 — // remotes force-assert Contact+OnWalkable every grounded tick, so // OnWalkable was equivalent there); (c) isInterpolating is false — @@ -14646,7 +14946,9 @@ public sealed class GameWindow : IDisposable { _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); _playerController.ObjectScale = playerAE.Scale; - _playerController.AttachAnimationRootMotionSource(AdvanceLocalPlayerAnimationRoot); + _playerController.AttachAnimationRootMotionSource( + AdvanceLocalPlayerAnimationRoot, + CaptureLocalPlayerAnimationHooks); // R3-W4: bind the player interp's retail seams to the player // sequencer — LeaveGround/HitGround strip transition links here // (the K-fix18 replacement). @@ -14662,10 +14964,8 @@ public sealed class GameWindow : IDisposable () => playerSeq.Manager.InitializeState(); _playerController.Motion.CheckForCompletedMotions = playerSeq.Manager.CheckForCompletedMotions; - // R3-W6: the player's cycles are now driven through the SAME - // dispatch sink remotes use (TurnApplied/TurnStopped omitted — - // ObservedOmega is a remote-DR-only concept; local rotation is - // the controller's Yaw integration). + // R3-W6: the player's cycles are driven through the same dispatch + // sink as remotes; both consume CSequence's complete root Frame. _playerController.Motion.DefaultSink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq); } @@ -14712,11 +15012,7 @@ public sealed class GameWindow : IDisposable playerEntity.ParentCellId = initResult.CellId; SyncLocalPlayerShadow(playerEntity, initResult.CellId, force: true); - var q = playerEntity.Rotation; - float rawYaw = MathF.Atan2( - 2f * (q.W * q.Z + q.X * q.Y), - 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); - _playerController.Yaw = rawYaw + MathF.PI / 2f; + _playerController.SetBodyOrientation(playerEntity.Rotation); _chaseCamera = new AcDream.App.Rendering.ChaseCamera { diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs new file mode 100644 index 00000000..62857f63 --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs @@ -0,0 +1,509 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter.Types; +using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity; +using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; + +namespace AcDream.App.Rendering; + +/// +/// Owns retail's ordinary live-object workset. CPhysics::UseTime +/// (0x00509950) walks the object table, not the render-animation table; an +/// animation, MovementManager, projectile body, or effect owner is therefore +/// an optional component of one canonical . +/// +internal sealed class LiveEntityAnimationScheduler +{ + private readonly Func _liveEntities; + private readonly Func _localPlayerGuid; + private readonly RemotePhysicsUpdater _remotePhysics; + private readonly LiveEntityOrdinaryPhysicsUpdater _ordinaryPhysics; + private readonly Func _projectiles; + private readonly Action _publishRootPose; + private readonly Action _captureAnimationHooks; + private readonly List _rootSnapshot = new(); + private readonly Dictionary _schedules = new(); + private readonly Frame _rootFrameScratch = new(); + private readonly MotionDeltaFrame _rootDeltaScratch = new(); + + public LiveEntityAnimationScheduler( + Func liveEntities, + Func localPlayerGuid, + RemotePhysicsUpdater remotePhysics, + LiveEntityOrdinaryPhysicsUpdater ordinaryPhysics, + Func projectiles, + Action publishRootPose, + Action captureAnimationHooks) + { + _liveEntities = liveEntities + ?? throw new ArgumentNullException(nameof(liveEntities)); + _localPlayerGuid = localPlayerGuid + ?? throw new ArgumentNullException(nameof(localPlayerGuid)); + _remotePhysics = remotePhysics + ?? throw new ArgumentNullException(nameof(remotePhysics)); + _ordinaryPhysics = ordinaryPhysics + ?? throw new ArgumentNullException(nameof(ordinaryPhysics)); + _projectiles = projectiles + ?? throw new ArgumentNullException(nameof(projectiles)); + _publishRootPose = publishRootPose + ?? throw new ArgumentNullException(nameof(publishRootPose)); + _captureAnimationHooks = captureAnimationHooks + ?? throw new ArgumentNullException(nameof(captureAnimationHooks)); + } + + /// + /// Advances a stable snapshot of the complete ordinary-object table and + /// returns pose work only for animation owners that survived every + /// callback. The returned dictionary is reused and valid until the next + /// call. + /// + public IReadOnlyDictionary Tick( + float elapsedSeconds, + Vector3? playerPosition, + bool localHiddenPartPoseDirty, + int liveCenterX, + int liveCenterY, + Action? prepareAnimation = null) + { + _schedules.Clear(); + LiveEntityRuntime? runtime = _liveEntities(); + if (runtime is null) + return _schedules; + + runtime.CopySpatialRootObjectRecordsTo(_rootSnapshot); + foreach (LiveEntityRecord record in _rootSnapshot) + { + if (!runtime.IsCurrentSpatialRootObject(record) + || record.WorldEntity is not { } entity) + { + continue; + } + + AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity; + RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion; + ProjectileController.Runtime? projectile = + record.ProjectileRuntime as ProjectileController.Runtime; + ulong objectClockEpoch = record.ObjectClockEpoch; + + if (animation is not null) + prepareAnimation?.Invoke(record.ServerGuid, animation); + if (!IsCurrent( + runtime, + record, + entity, + animation, + remote, + projectile, + objectClockEpoch)) + continue; + + LiveEntityAnimationSchedule schedule = AdvanceRecord( + runtime, + record, + entity, + animation, + remote, + projectile, + elapsedSeconds, + playerPosition, + localHiddenPartPoseDirty, + liveCenterX, + liveCenterY, + objectClockEpoch); + + if (animation is not null + && schedule.ComposeParts + && IsCurrent( + runtime, + record, + entity, + animation, + remote, + projectile, + objectClockEpoch)) + { + _schedules[entity.Id] = schedule; + } + } + + return _schedules; + } + + private LiveEntityAnimationSchedule AdvanceRecord( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + AnimatedEntity? animation, + RemoteMotion? remote, + ProjectileController.Runtime? projectile, + float elapsedSeconds, + Vector3? playerPosition, + bool localHiddenPartPoseDirty, + int liveCenterX, + int liveCenterY, + ulong objectClockEpoch) + { + uint serverGuid = record.ServerGuid; + AnimationSequencer? sequencer = animation?.Sequencer; + PhysicsStateFlags state = record.FinalPhysicsState; + bool hidden = (state & PhysicsStateFlags.Hidden) != 0; + + // PlayerMovementController owns this exact record clock and advances + // its PartArray before local collision. Consume that prepared pose; + // never tick the same clock or sequence a second time here. + if (serverGuid == _localPlayerGuid()) + { + IReadOnlyList? prepared = + animation?.PreparedSequenceFrames; + bool advanced = animation?.SequenceAdvancedBeforeAnimationPass == true; + if (animation is not null) + { + animation.PreparedSequenceFrames = null; + animation.SequenceAdvancedBeforeAnimationPass = false; + } + + bool composeHidden = hidden && localHiddenPartPoseDirty; + if (!IsCurrent( + runtime, + record, + entity, + animation, + remote, + projectile, + objectClockEpoch)) + { + return default; + } + + // Local projection interpolates the visible root on render frames + // that are smaller than an admitted object quantum. Publish that + // current root independently of PartArray recomposition so local + // attached effects and lights never trail the rendered character. + _publishRootPose(entity); + if (!IsCurrent( + runtime, + record, + entity, + animation, + remote, + projectile, + objectClockEpoch)) + { + return default; + } + + return new LiveEntityAnimationSchedule( + prepared ?? (composeHidden ? sequencer?.SampleCurrentPose() : null), + 0f, + ComposeParts: advanced || composeHidden); + } + + RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate( + record.ObjectClock, + remote?.Body ?? projectile?.Body ?? record.PhysicsBody, + runtime.GetRootObjectClockDisposition(serverGuid) + is RetailObjectClockDisposition.Advance, + record.HasPartArray, + (state & PhysicsStateFlags.Static) != 0, + entity.Position, + playerPosition, + elapsedSeconds); + if (activity is not RetailObjectActivityResult.Active) + return default; + + RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds); + if (batch.Count == 0) + return default; + + IReadOnlyList? frames = null; + float legacyElapsed = 0f; + bool completed = true; + ProjectileController? projectileController = _projectiles(); + bool projectileHandlesMovement = projectile is not null + && projectileController?.HandlesMovement(serverGuid) == true; + float objectScale = animation?.Scale + ?? record.Snapshot.Physics?.Scale + ?? record.Snapshot.ObjScale + ?? entity.Scale; + + for (int qi = 0; qi < batch.Count; qi++) + { + if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + { + completed = false; + break; + } + + float quantum = batch.GetQuantum(qi); + if (hidden) + { + if (remote is not null) + { + if (!_remotePhysics.TickHidden( + remote, + entity, + quantum, + sequencer?.Manager, + _captureAnimationHooks, + sequencer, + runtime, + record, + objectClockEpoch)) + { + completed = false; + break; + } + } + else + { + if (sequencer is not null) + { + _captureAnimationHooks(entity.Id, sequencer); + if (!IsCurrent( + runtime, + record, + entity, + animation, + remote, + projectile, + objectClockEpoch)) + { + completed = false; + break; + } + } + RunManagerTail(remote, sequencer?.Manager); + } + + if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + { + completed = false; + break; + } + continue; + } + + Frame rootFrame = animation?.RootMotionScratch ?? _rootFrameScratch; + rootFrame.Origin = Vector3.Zero; + rootFrame.Orientation = Quaternion.Identity; + if (sequencer is not null) + frames = sequencer.Advance(quantum, rootFrame); + else if (animation is not null) + legacyElapsed += quantum; + + if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + { + completed = false; + break; + } + + if (remote is not null && !projectileHandlesMovement) + { + if (animation is not null && quantum > 0f) + { + float rootMotionSpeed = rootFrame.Origin.Length() + * objectScale / quantum; + remote.MaxRootMotionSpeedSinceLastUP = MathF.Max( + remote.MaxRootMotionSpeedSinceLastUP, + rootMotionSpeed); + } + + MotionDeltaFrame rootDelta = animation?.RootMotionDeltaScratch + ?? _rootDeltaScratch; + rootDelta.Origin = rootFrame.Origin; + rootDelta.Orientation = rootFrame.Orientation; + if (!_remotePhysics.Tick( + remote, + entity, + objectScale, + sequencer, + animation, + quantum, + rootDelta, + liveCenterX, + liveCenterY, + _captureAnimationHooks, + runtime, + record, + objectClockEpoch)) + { + completed = false; + break; + } + } + else + { + bool ordinaryBodyHandlesMovement = projectile is null + && record.PhysicsBody is not null; + if (ordinaryBodyHandlesMovement) + { + if (!_ordinaryPhysics.Tick( + runtime, + record, + entity, + rootFrame, + objectScale, + quantum, + liveCenterX, + liveCenterY, + objectClockEpoch, + sequencer, + _captureAnimationHooks)) + { + completed = false; + break; + } + } + else + { + ApplyRootFrame(record, entity, rootFrame, objectScale); + } + if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + { + completed = false; + break; + } + + ProjectileController.QuantumStep projectileStep = default; + bool beganProjectile = projectileHandlesMovement + && projectileController?.TryBeginQuantum( + record, + quantum, + out projectileStep) == true; + + if (!ordinaryBodyHandlesMovement && sequencer is not null) + _captureAnimationHooks(entity.Id, sequencer); + if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + { + completed = false; + break; + } + + if (beganProjectile + && !projectileController!.CompleteQuantum( + projectileStep, + liveCenterX, + liveCenterY)) + { + completed = false; + break; + } + if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + { + completed = false; + break; + } + + RunManagerTail(remote, sequencer?.Manager); + } + + if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + { + completed = false; + break; + } + } + + if (!completed + || !IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + { + return default; + } + + _publishRootPose(entity); + if (!IsCurrent(runtime, record, entity, animation, remote, projectile, objectClockEpoch)) + return default; + + if (hidden) + { + return new LiveEntityAnimationSchedule( + sequencer?.SampleCurrentPose(), + 0f, + ComposeParts: animation is not null); + } + + return new LiveEntityAnimationSchedule( + frames, + legacyElapsed, + ComposeParts: animation is not null); + } + + private static void ApplyRootFrame( + LiveEntityRecord record, + WorldEntity entity, + Frame rootFrame, + float objectScale) + { + PhysicsBody? body = record.PhysicsBody; + Vector3 position = body?.Position ?? entity.Position; + Quaternion orientation = body?.Orientation ?? entity.Rotation; + Vector3 localOrigin = body?.OnWalkable == true + ? rootFrame.Origin * objectScale + : Vector3.Zero; + + if (localOrigin != Vector3.Zero) + position += Vector3.Transform(localOrigin, orientation); + if (!rootFrame.Orientation.IsIdentity) + { + orientation = FrameOps.SetRotate( + position, + orientation, + orientation * rootFrame.Orientation); + } + + if (body is not null) + { + body.Position = position; + body.Orientation = orientation; + // Projectile integration follows this compose. Manager-less + // ordinary bodies use LiveEntityOrdinaryPhysicsUpdater so their + // complete Frame travels through Transition/SetPositionInternal. + } + + entity.SetPosition(position); + entity.Rotation = orientation; + } + + private static bool IsCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + AnimatedEntity? animation, + RemoteMotion? remote, + ProjectileController.Runtime? projectile, + ulong objectClockEpoch) => + runtime.IsCurrentSpatialRootObject(record) + && record.ObjectClockEpoch == objectClockEpoch + && ReferenceEquals(record.WorldEntity, entity) + && ReferenceEquals(record.AnimationRuntime, animation) + && ReferenceEquals(record.RemoteMotionRuntime, remote) + && ReferenceEquals(record.ProjectileRuntime, projectile) + && (animation is null || runtime.IsCurrentSpatialAnimation(record, animation)); + + private static void RunManagerTail( + RemoteMotion? remote, + MotionTableManager? partArray) + { + if (remote is not null) + { + RetailObjectManagerTail.Run( + remote.Host?.TargetManager, + remote.Movement, + partArray, + remote.Host?.PositionManager); + } + else + { + partArray?.UseTime(); + } + } +} + +internal readonly record struct LiveEntityAnimationSchedule( + IReadOnlyList? SequenceFrames, + float LegacyAdvanceSeconds, + bool ComposeParts); diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs new file mode 100644 index 00000000..534ba13f --- /dev/null +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -0,0 +1,432 @@ +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Rendering; + +/// +/// Ports retail's separate CPhysics::static_animating_objects workset +/// (CPhysics::UseTime 0x00509950 and +/// CPhysicsObj::animate_static_object 0x00513DF0). Membership is +/// Setup/state-driven: any physics-Static object with Setup.DefaultAnimation +/// enters, whether DAT-hydrated or server-created, and leaves with its logical +/// resource lifetime. Setup default installation itself is unconditional and +/// belongs to PartArray construction; this class owns only the Static workset. +/// +internal sealed class RetailStaticAnimatingObjectScheduler +{ + private const double FrameEpsilon = 0.000199999995; + private const float MaximumElapsed = 2f; + + private sealed class Owner + { + public required WorldEntity Entity; + public required Setup Setup; + public required AnimationSequencer? Sequencer; + public required uint[] PartGfxIds; + public required IReadOnlyDictionary?[] SurfaceOverrides; + public required bool[] PartAvailable; + public PhysicsBody? Body; + public AnimationSequencer? PendingProcessHooks; + public ulong PendingResidencyVersion; + public IReadOnlyList? PreparedLivePartFrames; + public double ElapsedSinceUpdate; + public readonly Frame RootFrameScratch = new(); + public readonly List MeshRefs = new(); + public readonly List PartPoses = new(); + } + + private readonly IAnimationLoader _animationLoader; + private readonly Action _captureHooks; + private readonly Action, IReadOnlyList> + _publishPartPoses; + private readonly Func _isResident; + private readonly Action _commitLiveRoot; + private readonly Func _residencyVersion; + private readonly Dictionary _owners = new(); + private readonly List _snapshot = new(); + private readonly List _hookSnapshot = new(); + + public RetailStaticAnimatingObjectScheduler( + IAnimationLoader animationLoader, + Action captureHooks, + Action, IReadOnlyList> publishPartPoses, + Func? isResident = null, + Action? commitLiveRoot = null, + Func? residencyVersion = null) + { + _animationLoader = animationLoader + ?? throw new ArgumentNullException(nameof(animationLoader)); + _captureHooks = captureHooks + ?? throw new ArgumentNullException(nameof(captureHooks)); + _publishPartPoses = publishPartPoses + ?? throw new ArgumentNullException(nameof(publishPartPoses)); + _isResident = isResident ?? (_ => true); + _commitLiveRoot = commitLiveRoot ?? ((_, _) => { }); + _residencyVersion = residencyVersion ?? (_ => 0UL); + } + + internal int Count => _owners.Count; + + public void Register(WorldEntity entity, ScriptActivationInfo info) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(info); + if (!info.UsesStaticAnimationWorkset + || info.Setup is not { } setup + || info.DefaultAnimationId == 0) + { + return; + } + + if (_owners.TryGetValue(entity.Id, out Owner? retained)) + { + if (ReferenceEquals(retained.Entity, entity)) + return; + throw new InvalidOperationException( + $"DAT-static animation owner 0x{entity.Id:X8} is already registered."); + } + + // A live CPhysicsObj already owns its canonical PartArray through + // AnimatedEntity. Resource registration occurs before that App owner + // is constructed, so retain a pending workset member and bind the + // exact sequencer later. DAT-only statics have no live owner and may + // construct their PartArray here. + AnimationSequencer? sequencer = null; + if (entity.ServerGuid == 0) + { + sequencer = new AnimationSequencer( + setup, + new MotionTable(), + _animationLoader); + if (!sequencer.HasCurrentNode) + return; + } + + int partCount = setup.Parts.Count; + uint[] gfxIds = BuildPartGfxIds(setup, entity); + bool[] available = new bool[partCount]; + if (info.PartAvailability is { } supplied) + { + for (int i = 0; i < partCount && i < supplied.Count; i++) + available[i] = supplied[i]; + } + var surfaces = MatchSurfaceOverrides(entity, gfxIds, available); + _owners.Add(entity.Id, new Owner + { + Entity = entity, + Setup = setup, + Sequencer = sequencer, + PartGfxIds = gfxIds, + SurfaceOverrides = surfaces, + PartAvailable = available, + }); + } + + /// + /// Completes a live Physics-Static registration with the same sequencer + /// and body owned by the canonical live entity. + /// Retail's static workset stores a CPhysicsObj*; it never clones a + /// second PartArray for this alternate scheduling path. + /// + public bool BindLiveOwner( + WorldEntity entity, + AnimationSequencer sequencer, + PhysicsBody body) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(sequencer); + ArgumentNullException.ThrowIfNull(body); + if (!_owners.TryGetValue(entity.Id, out Owner? owner)) + return false; + if (!ReferenceEquals(owner.Entity, entity) || entity.ServerGuid == 0) + { + throw new InvalidOperationException( + $"Static animation owner 0x{entity.Id:X8} does not match this live incarnation."); + } + if (!sequencer.HasCurrentNode) + return false; + + owner.Sequencer = sequencer; + owner.Body = body; + return true; + } + + /// + /// Transfers the current live PartArray result to GameWindow's canonical + /// appearance composer. The scheduler owns timing; AnimatedEntity remains + /// the single owner of mesh identity, overrides, and appearance rebinding. + /// + public bool TryTakeLivePartFrames( + uint ownerId, + out IReadOnlyList frames) + { + if (_owners.TryGetValue(ownerId, out Owner? owner) + && owner.Entity.ServerGuid != 0 + && owner.PreparedLivePartFrames is { } prepared + && IsResidentAtVersion(owner, owner.PendingResidencyVersion)) + { + owner.PreparedLivePartFrames = null; + frames = prepared; + return true; + } + + if (_owners.TryGetValue(ownerId, out owner)) + InvalidatePending(owner); + + frames = Array.Empty(); + return false; + } + + public void Unregister(uint ownerId) => _owners.Remove(ownerId); + + public void CopyAnimatedEntityIdsTo(HashSet destination) + { + ArgumentNullException.ThrowIfNull(destination); + foreach ((uint ownerId, Owner owner) in _owners) + { + if (owner.Sequencer is not null) + destination.Add(ownerId); + } + } + + public void Tick(float elapsedSeconds) + { + if (!float.IsFinite(elapsedSeconds) + || elapsedSeconds <= 0f + || _owners.Count == 0) + { + return; + } + + _snapshot.Clear(); + _snapshot.AddRange(_owners.Values); + foreach (Owner owner in _snapshot) + { + if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current) + || !ReferenceEquals(current, owner) + || owner.Sequencer is not { } sequencer) + { + continue; + } + + owner.ElapsedSinceUpdate += elapsedSeconds; + if (!_isResident(owner.Entity)) + { + InvalidatePending(owner); + // animate_static_object returns before touching update_time + // while cell == null. A short leave-world interval therefore + // catches up on re-entry; a long one is discarded by the + // retail two-second guard below. + continue; + } + ulong residencyVersion = _residencyVersion(owner.Entity); + + double ownerElapsed = owner.ElapsedSinceUpdate; + owner.ElapsedSinceUpdate = 0d; + if (ownerElapsed <= FrameEpsilon + || ownerElapsed > MaximumElapsed) + { + continue; + } + + IReadOnlyList frames = sequencer.Advance( + (float)ownerElapsed); + + if (owner.Body is { } body) + { + // CPhysicsObj::animate_static_object 0x00513DF0 passes the + // stored omega vector directly to Frame::grotate after the + // PartArray update. Unlike UpdatePhysicsInternal, this odd + // static branch does not multiply omega by elapsed time. + Quaternion previousOrientation = body.Orientation; + owner.RootFrameScratch.Origin = body.Position; + owner.RootFrameScratch.Orientation = previousOrientation; + FrameOps.GRotate(owner.RootFrameScratch, body.Omega); + body.SetFrameInCurrentCell( + body.Position, + owner.RootFrameScratch.Orientation); + owner.Entity.Rotation = body.Orientation; + // animate_static_object rotates the root before + // UpdatePartsInternal/UpdateChildrenInternal. Commit every + // root consumer (collision shadows and effect anchors) at + // that same boundary; a rendered-only rotation leaves the + // canonical CPhysicsObj split across two orientations. + // The common case is a zero omega. Do not turn that no-op + // into a per-frame ShadowObjectRegistry reflood; only a real + // root change has canonical consumers to commit. + if (body.Orientation != previousOrientation) + { + _commitLiveRoot(owner.Entity, body); + if (!_owners.TryGetValue(owner.Entity.Id, out current) + || !ReferenceEquals(current, owner) + || !IsResidentAtVersion(owner, residencyVersion)) + { + InvalidatePending(owner); + continue; + } + } + } + + if (!IsResidentAtVersion(owner, residencyVersion)) + { + InvalidatePending(owner); + continue; + } + + if (owner.Entity.ServerGuid != 0) + { + owner.PreparedLivePartFrames = frames; + } + else + { + Compose(owner, frames); + _publishPartPoses(owner.Entity, owner.PartPoses, owner.PartAvailable); + if (!_owners.TryGetValue(owner.Entity.Id, out current) + || !ReferenceEquals(current, owner) + || !IsResidentAtVersion(owner, residencyVersion)) + { + InvalidatePending(owner); + continue; + } + } + + // animate_static_object runs process_hooks only after the root, + // parts, and attached children have their final transforms. Live + // part/child publication occurs outside this owner, so retain the + // exact PartArray and let GameWindow call ProcessHooks at that + // later boundary instead of completing AnimationDone here. + owner.PendingProcessHooks = sequencer; + owner.PendingResidencyVersion = residencyVersion; + } + } + + /// + /// Runs the static workset's retail process_hooks tail after final + /// root, part, and child pose publication. Presentation hooks remain in + /// the shared frame queue until its normal drain. + /// + public void ProcessHooks() + { + _hookSnapshot.Clear(); + foreach (Owner owner in _owners.Values) + { + if (owner.PendingProcessHooks is not null) + _hookSnapshot.Add(owner); + } + + foreach (Owner owner in _hookSnapshot) + { + if (!_owners.TryGetValue(owner.Entity.Id, out Owner? current) + || !ReferenceEquals(current, owner) + || owner.PendingProcessHooks is not { } sequencer + || !ReferenceEquals(owner.Sequencer, sequencer) + || !IsResidentAtVersion(owner, owner.PendingResidencyVersion)) + { + InvalidatePending(owner); + continue; + } + + // Clear before the callback: hook delivery may unregister or + // replace the owner, and a nested caller must not replay this tail. + owner.PendingProcessHooks = null; + _captureHooks(owner.Entity.Id, sequencer); + } + } + + private bool IsResidentAtVersion(Owner owner, ulong version) => + _isResident(owner.Entity) + && _residencyVersion(owner.Entity) == version; + + private static void InvalidatePending(Owner owner) + { + owner.PreparedLivePartFrames = null; + owner.PendingProcessHooks = null; + owner.PendingResidencyVersion = 0UL; + } + + private static void Compose(Owner owner, IReadOnlyList frames) + { + owner.MeshRefs.Clear(); + owner.PartPoses.Clear(); + Matrix4x4 objectScale = owner.Entity.Scale == 1f + ? Matrix4x4.Identity + : Matrix4x4.CreateScale(owner.Entity.Scale); + + int partCount = owner.Setup.Parts.Count; + for (int i = 0; i < partCount; i++) + { + Vector3 origin = i < frames.Count ? frames[i].Origin : Vector3.Zero; + Quaternion orientation = i < frames.Count + ? frames[i].Orientation + : Quaternion.Identity; + Vector3 defaultScale = i < owner.Setup.DefaultScale.Count + ? owner.Setup.DefaultScale[i] + : Vector3.One; + + Matrix4x4 visual = + Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + if (owner.Entity.Scale != 1f) + visual *= objectScale; + + owner.PartPoses.Add( + Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin * owner.Entity.Scale)); + if (!owner.PartAvailable[i]) + continue; + + owner.MeshRefs.Add(new MeshRef(owner.PartGfxIds[i], visual) + { + SurfaceOverrides = owner.SurfaceOverrides[i], + }); + } + + owner.Entity.MeshRefs = owner.MeshRefs; + owner.Entity.SetIndexedPartPoses(owner.PartPoses, owner.PartAvailable); + } + + private static uint[] BuildPartGfxIds(Setup setup, WorldEntity entity) + { + var result = new uint[setup.Parts.Count]; + for (int i = 0; i < result.Length; i++) + result[i] = (uint)setup.Parts[i]; + foreach (PartOverride replacement in entity.PartOverrides) + { + if (replacement.PartIndex < result.Length) + result[replacement.PartIndex] = replacement.GfxObjId; + } + return result; + } + + private static IReadOnlyDictionary?[] MatchSurfaceOverrides( + WorldEntity entity, + uint[] gfxIds, + bool[] available) + { + var result = new IReadOnlyDictionary?[gfxIds.Length]; + var consumed = new bool[entity.MeshRefs.Count]; + for (int partIndex = 0; partIndex < gfxIds.Length; partIndex++) + { + for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++) + { + if (consumed[meshIndex] + || entity.MeshRefs[meshIndex].GfxObjId != gfxIds[partIndex]) + { + continue; + } + + consumed[meshIndex] = true; + available[partIndex] = true; + result[partIndex] = entity.MeshRefs[meshIndex].SurfaceOverrides; + break; + } + } + return result; + } +} diff --git a/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs b/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs new file mode 100644 index 00000000..a41696cc --- /dev/null +++ b/src/AcDream.App/Rendering/StaticLiveRootCommitter.cs @@ -0,0 +1,90 @@ +using AcDream.App.Physics; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Commits a changed root produced by retail's +/// CPhysicsObj::animate_static_object to the retained effect pose and +/// collision-shadow projections of the same live object incarnation. +/// +internal sealed class StaticLiveRootCommitter +{ + private readonly Func _runtime; + private readonly ShadowObjectRegistry _shadows; + private readonly Func<(int X, int Y)> _liveCenter; + private readonly Action _updateEffectRoot; + + public StaticLiveRootCommitter( + Func runtime, + ShadowObjectRegistry shadows, + Func<(int X, int Y)> liveCenter, + Action updateEffectRoot) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); + _liveCenter = liveCenter + ?? throw new ArgumentNullException(nameof(liveCenter)); + _updateEffectRoot = updateEffectRoot + ?? throw new ArgumentNullException(nameof(updateEffectRoot)); + } + + public bool Commit(WorldEntity entity, PhysicsBody body) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(body); + LiveEntityRuntime? runtime = _runtime(); + if (runtime is null + || !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record) + || !ReferenceEquals(record.WorldEntity, entity) + || !ReferenceEquals(record.PhysicsBody, body)) + { + return false; + } + + // The retained pose owner follows the CPhysicsObj root even while its + // mesh is hidden. Hidden is presentation state, not logical teardown. + _updateEffectRoot(entity); + + // EffectPoseChanged observers run synchronously and may delete this + // incarnation (or replace the GUID) before returning. Do not let the + // stale root restore collision shadows for an owner which no longer + // exists. This is the same callback-boundary lifetime rule used by the + // live schedulers and movement controllers. + if (!ReferenceEquals(_runtime(), runtime) + || !runtime.TryGetRecord(entity.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, record) + || !ReferenceEquals(current.WorldEntity, entity) + || !ReferenceEquals(current.PhysicsBody, body)) + { + return false; + } + + // CObjCell::hide_object removes shadows. Never let a later static + // animation tick re-register collision for a hidden, withdrawn, or + // pending projection; UnHide/re-entry owns restoration. + if (record.ProjectionKind is not LiveEntityProjectionKind.World + || !record.IsSpatiallyProjected + || !record.IsSpatiallyVisible + || runtime.IsHidden(entity.ServerGuid)) + { + return true; + } + + uint cellId = body.CellPosition.ObjCellId != 0 + ? body.CellPosition.ObjCellId + : record.FullCellId; + (int centerX, int centerY) = _liveCenter(); + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + body.Position, + body.Orientation, + cellId, + centerX, + centerY); + return true; + } +} diff --git a/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs b/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs index d729d2a8..578a3b65 100644 --- a/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs +++ b/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs @@ -20,6 +20,7 @@ public sealed class AnimationHookFrameQueue { private readonly AnimationHookRouter _router; private readonly IEntityEffectPoseSource _poses; + private readonly IEntityEffectPoseLifetimeSource? _poseLifetimes; private readonly List _entries = new(); public AnimationHookFrameQueue( @@ -28,6 +29,7 @@ public sealed class AnimationHookFrameQueue { _router = router ?? throw new ArgumentNullException(nameof(router)); _poses = poses ?? throw new ArgumentNullException(nameof(poses)); + _poseLifetimes = poses as IEntityEffectPoseLifetimeSource; } public int Count => _entries.Count; @@ -45,6 +47,12 @@ public sealed class AnimationHookFrameQueue { ArgumentNullException.ThrowIfNull(sequencer); ArgumentNullException.ThrowIfNull(hooks); + // Capture incarnation identity before AnimationDone: its semantic + // callback can synchronously delete this owner and reuse the local ID. + // Reading the version afterwards would mislabel the old hook batch as + // belonging to the replacement. + ulong ownerLifetimeVersion = + _poseLifetimes?.GetPoseOwnerLifetimeVersion(ownerLocalId) ?? 0UL; // Retail CPhysicsObj::process_hooks (0x00511550) executes AnimDone // inside UpdatePositionInternal, before the transition and every @@ -55,12 +63,25 @@ public sealed class AnimationHookFrameQueue // authored hook stream after the final part poses are published. for (int i = 0; i < hooks.Count; i++) { + // MotionDone can synchronously delete this owner and reuse the + // local ID. Do not advance the displaced sequencer for later + // AnimDone hooks from the old catch-up batch. + if (_poseLifetimes is not null + && _poseLifetimes.GetPoseOwnerLifetimeVersion( + ownerLocalId) != ownerLifetimeVersion) + { + break; + } if (hooks[i] is AnimationDoneHook) sequencer.Manager.AnimationDone(success: true); } + if (hooks.Count == 0) + return; + _entries.Add(new Entry( ownerLocalId, + ownerLifetimeVersion, hooks)); } @@ -69,19 +90,35 @@ public sealed class AnimationHookFrameQueue for (int i = 0; i < _entries.Count; i++) { Entry entry = _entries[i]; - bool hasRootPose = _poses.TryGetRootPose( - entry.OwnerLocalId, - out Matrix4x4 rootWorld); - Vector3 worldPosition = hasRootPose - ? rootWorld.Translation - : Vector3.Zero; + if (_poseLifetimes is not null + && _poseLifetimes.GetPoseOwnerLifetimeVersion( + entry.OwnerLocalId) != entry.OwnerLifetimeVersion) + { + continue; + } for (int hi = 0; hi < entry.Hooks.Count; hi++) { + // A prior hook sink can tear down and replace this local ID. + // Revalidate per hook so the remainder of the old PES/animation + // batch can never spill into the replacement incarnation. + if (_poseLifetimes is not null + && _poseLifetimes.GetPoseOwnerLifetimeVersion( + entry.OwnerLocalId) != entry.OwnerLifetimeVersion) + { + break; + } AnimationHook? hook = entry.Hooks[hi]; if (hook is null) continue; - if (hasRootPose) - _router.OnHook(entry.OwnerLocalId, worldPosition, hook); + if (_poses.TryGetRootPose( + entry.OwnerLocalId, + out Matrix4x4 rootWorld)) + { + _router.OnHook( + entry.OwnerLocalId, + rootWorld.Translation, + hook); + } } } _entries.Clear(); @@ -91,5 +128,6 @@ public sealed class AnimationHookFrameQueue private readonly record struct Entry( uint OwnerLocalId, + ulong OwnerLifetimeVersion, IReadOnlyList Hooks); } diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs index 168596c7..a4b8ccfc 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs @@ -18,7 +18,8 @@ namespace AcDream.App.Rendering.Vfx; public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityEffectCellSource, - IEntityEffectPoseChangeSource + IEntityEffectPoseChangeSource, + IEntityEffectPoseLifetimeSource { private sealed class PoseRecord { @@ -26,9 +27,11 @@ public sealed class EntityEffectPoseRegistry : public Matrix4x4[] PartLocal = Array.Empty(); public bool[] PartAvailable = Array.Empty(); public uint CellId; + public ulong LifetimeVersion; } private readonly Dictionary _poses = new(); + private ulong _nextLifetimeVersion; public event Action? EffectPoseChanged; @@ -68,7 +71,7 @@ public sealed class EntityEffectPoseRegistry : bool changed; if (!_poses.TryGetValue(entity.Id, out PoseRecord? record)) { - record = new PoseRecord(); + record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() }; _poses.Add(entity.Id, record); changed = true; } @@ -126,7 +129,7 @@ public sealed class EntityEffectPoseRegistry : bool changed; if (!_poses.TryGetValue(localEntityId, out PoseRecord? record)) { - record = new PoseRecord(); + record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() }; _poses.Add(localEntityId, record); changed = true; } @@ -176,7 +179,9 @@ public sealed class EntityEffectPoseRegistry : uint[] removedOwners = _poses.Keys.ToArray(); _poses.Clear(); foreach (uint owner in removedOwners) + { EffectPoseChanged?.Invoke(owner); + } } public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) @@ -248,6 +253,19 @@ public sealed class EntityEffectPoseRegistry : return false; } + public ulong GetPoseOwnerLifetimeVersion(uint localEntityId) => + _poses.TryGetValue(localEntityId, out PoseRecord? record) + ? record.LifetimeVersion + : 0UL; + + private ulong NextLifetimeVersion() + { + _nextLifetimeVersion++; + if (_nextLifetimeVersion == 0UL) + _nextLifetimeVersion++; + return _nextLifetimeVersion; + } + private static bool CopyParts( PoseRecord record, IReadOnlyList partLocal, @@ -277,3 +295,8 @@ public sealed class EntityEffectPoseRegistry : return changed; } } + +public interface IEntityEffectPoseLifetimeSource +{ + ulong GetPoseOwnerLifetimeVersion(uint localEntityId); +} diff --git a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs index a1138a4b..f314c9fd 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs @@ -12,7 +12,10 @@ public sealed record ScriptActivationInfo( uint ScriptId, IReadOnlyList PartTransforms, EntityEffectProfile? EffectProfile = null, - IReadOnlyList? PartAvailability = null); + IReadOnlyList? PartAvailability = null, + DatReaderWriter.DBObjs.Setup? Setup = null, + uint DefaultAnimationId = 0, + bool UsesStaticAnimationWorkset = false); /// /// Owns create-time Setup script activation and its symmetric teardown for @@ -25,13 +28,29 @@ public sealed record ScriptActivationInfo( /// public sealed class EntityScriptActivator { + private sealed class StaticOwnerState + { + public required WorldEntity Entity; + public required ScriptActivationInfo Info; + public bool PosePublished; + public bool EffectOwnerRegistered; + public bool AnimationOwnerRegistered; + public bool ScriptStarted; + public bool ReleaseStarted; + public bool ScriptsStopped; + public bool EmittersStopped; + public bool PoseRemoved; + } + private readonly PhysicsScriptRunner _scriptRunner; private readonly ParticleHookSink _particleSink; private readonly EntityEffectPoseRegistry _poses; private readonly Func _resolver; private readonly Action? _registerDatStaticEffectOwner; private readonly Action? _unregisterDatStaticEffectOwner; - private readonly Dictionary _activeStaticOwners = new(); + private readonly Action? _registerDatStaticAnimationOwner; + private readonly Action? _unregisterDatStaticAnimationOwner; + private readonly Dictionary _staticOwners = new(); public EntityScriptActivator( PhysicsScriptRunner scriptRunner, @@ -39,7 +58,9 @@ public sealed class EntityScriptActivator EntityEffectPoseRegistry poses, Func resolver, Action? registerDatStaticEffectOwner = null, - Action? unregisterDatStaticEffectOwner = null) + Action? unregisterDatStaticEffectOwner = null, + Action? registerDatStaticAnimationOwner = null, + Action? unregisterDatStaticAnimationOwner = null) { _scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner)); _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); @@ -47,6 +68,8 @@ public sealed class EntityScriptActivator _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _registerDatStaticEffectOwner = registerDatStaticEffectOwner; _unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner; + _registerDatStaticAnimationOwner = registerDatStaticAnimationOwner; + _unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner; } public void OnCreate(WorldEntity entity) @@ -57,33 +80,93 @@ public sealed class EntityScriptActivator return; if (entity.ServerGuid == 0 - && _activeStaticOwners.TryGetValue(key, out WorldEntity? existing)) + && _staticOwners.TryGetValue(key, out StaticOwnerState? retained)) { - if (ReferenceEquals(existing, entity)) - return; - throw new InvalidOperationException( - $"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique."); + if (!ReferenceEquals(retained.Entity, entity)) + { + throw new InvalidOperationException( + $"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique."); + } + if (retained.ReleaseStarted) + { + throw new InvalidOperationException( + $"Dat-static WorldEntity id 0x{key:X8} cannot reactivate while teardown is pending."); + } + + ActivateStaticOwner(retained); + return; } ScriptActivationInfo? info = _resolver(entity); if (info is null) return; - // Particle::Init (0x0051C930) samples the current root/part frames. - // Publish before the Setup default script can execute; effects never - // fall back to the camera or a cached spawn anchor. - _poses.Publish(entity, info.PartTransforms, info.PartAvailability); - if (entity.ServerGuid == 0) - _activeStaticOwners.Add(key, entity); - - if (entity.ServerGuid == 0 && info.EffectProfile is { } profile) - _registerDatStaticEffectOwner?.Invoke(key, entity, profile); + { + var state = new StaticOwnerState + { + Entity = entity, + Info = info, + }; + _staticOwners.Add(key, state); + ActivateStaticOwner(state); + return; + } + // Live registration is an atomic logical-lifetime edge owned by + // LiveEntityRuntime and is never retried at this activator in isolation. + _poses.Publish(entity, info.PartTransforms, info.PartAvailability); + if (info.UsesStaticAnimationWorkset + && info.DefaultAnimationId != 0) + { + _registerDatStaticAnimationOwner?.Invoke(entity, info); + } if (info.ScriptId != 0) _scriptRunner.Play(info.ScriptId, key, entity.Position); } + private void ActivateStaticOwner(StaticOwnerState state) + { + WorldEntity entity = state.Entity; + ScriptActivationInfo info = state.Info; + uint key = entity.Id; + + // Particle::Init (0x0051C930) samples the current root/part frames. + // Commit each acquisition only after its callback returns. A later + // failure can then retry without replaying completed create-time work. + if (!state.PosePublished) + { + _poses.Publish(entity, info.PartTransforms, info.PartAvailability); + state.PosePublished = true; + } + + if (!state.EffectOwnerRegistered + && info.EffectProfile is { } profile + && _registerDatStaticEffectOwner is not null) + { + _registerDatStaticEffectOwner(key, entity, profile); + state.EffectOwnerRegistered = true; + } + + // TS-51: script-only statics still use the shared script runner. The + // dedicated static animation scheduler owns only real DefaultAnimation + // PartArray work, so script-only owners incur no empty frame scan. + if (!state.AnimationOwnerRegistered + && info.UsesStaticAnimationWorkset + && info.DefaultAnimationId != 0 + && _registerDatStaticAnimationOwner is not null) + { + _registerDatStaticAnimationOwner(entity, info); + state.AnimationOwnerRegistered = true; + } + + if (!state.ScriptStarted && info.ScriptId != 0) + { + _scriptRunner.Play(info.ScriptId, key, entity.Position); + state.ScriptStarted = true; + } + } + public void OnRemove(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); @@ -91,17 +174,48 @@ public sealed class EntityScriptActivator if (key == 0) return; - if (entity.ServerGuid == 0 - && (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing) - || !ReferenceEquals(existing, entity))) + if (entity.ServerGuid == 0) { + if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state) + || !ReferenceEquals(state.Entity, entity)) + { + return; + } + + state.ReleaseStarted = true; + if (!state.ScriptsStopped) + { + _scriptRunner.StopAllForEntity(key); + state.ScriptsStopped = true; + } + if (!state.EmittersStopped) + { + _particleSink.StopAllForEntity(key, fadeOut: false); + state.EmittersStopped = true; + } + if (!state.PoseRemoved) + { + _poses.Remove(key); + state.PoseRemoved = true; + } + if (state.AnimationOwnerRegistered) + { + _unregisterDatStaticAnimationOwner?.Invoke(key); + state.AnimationOwnerRegistered = false; + } + if (state.EffectOwnerRegistered) + { + _unregisterDatStaticEffectOwner?.Invoke(key); + state.EffectOwnerRegistered = false; + } + + _staticOwners.Remove(key); return; } + _unregisterDatStaticAnimationOwner?.Invoke(key); _scriptRunner.StopAllForEntity(key); _particleSink.StopAllForEntity(key, fadeOut: false); _poses.Remove(key); - if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key)) - _unregisterDatStaticEffectOwner?.Invoke(key); } } diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs index fd2747d7..ff0ee778 100644 --- a/src/AcDream.App/World/LiveEntityPresentationController.cs +++ b/src/AcDream.App/World/LiveEntityPresentationController.cs @@ -31,6 +31,7 @@ public sealed class LiveEntityPresentationController : IDisposable private readonly Dictionary _readyGenerationByGuid = new(); private readonly Dictionary _suspendedShadowGenerationByGuid = new(); private readonly Dictionary _activePlacementGenerationByGuid = new(); + private readonly HashSet _drainingRecords = new(); private bool _disposed; public LiveEntityPresentationController( @@ -69,8 +70,20 @@ public sealed class LiveEntityPresentationController : IDisposable } _readyGenerationByGuid[serverGuid] = record.Generation; - ApplyPendingTransitions(record); - return true; + if (!ApplyPendingTransitions(record) + || record.WorldEntity is not { } entity + || !IsCurrent(record, entity)) + { + return false; + } + + // An object can materialize into a pending landblock before collision + // registration and before this ready barrier opens. Its initial false + // visibility edge therefore had nothing to suspend. Reconcile here, + // after every create-time owner exists, so the retained registration + // cannot remain active offscreen and hydration has a restore marker. + SuspendOrdinaryShadowOutsideProjection(record, entity); + return IsCurrent(record, entity); } /// Drains a newly accepted SetState when this incarnation is ready. @@ -83,8 +96,7 @@ public sealed class LiveEntityPresentationController : IDisposable return false; } - ApplyPendingTransitions(record); - return true; + return ApplyPendingTransitions(record); } /// @@ -188,6 +200,7 @@ public sealed class LiveEntityPresentationController : IDisposable { _activePlacementGenerationByGuid.Remove(record.ServerGuid); } + _drainingRecords.Remove(record); } public void Clear() @@ -195,6 +208,7 @@ public sealed class LiveEntityPresentationController : IDisposable _readyGenerationByGuid.Clear(); _suspendedShadowGenerationByGuid.Clear(); _activePlacementGenerationByGuid.Clear(); + _drainingRecords.Clear(); } public void Dispose() @@ -206,46 +220,83 @@ public sealed class LiveEntityPresentationController : IDisposable Clear(); } - private void ApplyPendingTransitions(LiveEntityRecord record) + private bool ApplyPendingTransitions(LiveEntityRecord record) { - while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition)) + // State callbacks can synchronously accept another SetState. Retail's + // update thread completes the current transition before the next FIFO + // entry; a recursive drain would interleave Visible halfway through + // Hidden and then let the older side effects win last. + if (!_drainingRecords.Add(record)) + return IsCurrent(record, record.WorldEntity); + + try { - if (record.WorldEntity is not { } entity) - return; - - // set_state writes the final state before any PartArray/cell side - // effect. Retained shadow registrations must see those same bits - // even while their cell rows are suspended. - _shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState); - - switch (transition.HiddenTransition) + while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition)) { - case RetailHiddenTransition.BecameHidden: - _playTyped(entity.Id, HiddenScriptType, 1f); - _setDirectChildrenNoDraw(record.ServerGuid, true); - _shadows.Suspend(entity.Id); - if (!IsPlacementActive(record)) - _suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation; - // Retail CPhysicsObj::set_hidden @ 0x00514C60 calls - // CPartArray::HandleEnterWorld after hiding the object - // from its cell. Despite the name, this is the motion - // timeline boundary: it strips link animations and - // aborts every pending completion through - // MotionTableManager::HandleEnterWorld @ 0x0051BDD0. - _handlePartArrayEnterWorld(entity.Id); - _clearInvalidTarget(record.ServerGuid); - break; + if (record.WorldEntity is not { } entity + || !IsCurrent(record, entity)) + { + return false; + } - case RetailHiddenTransition.BecameVisible: - _playTyped(entity.Id, UnHideScriptType, 1f); - _setDirectChildrenNoDraw(record.ServerGuid, false); - // Retail invokes the same PartArray boundary before - // CObjCell::unhide_object restores cell visibility. - _handlePartArrayEnterWorld(entity.Id); - if (!IsPlacementActive(record) && RestoreShadow(record, entity)) - _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); - break; + // set_state writes the final state before any PartArray/cell side + // effect. Retained shadow registrations must see those same bits + // even while their cell rows are suspended. + _shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState); + + switch (transition.HiddenTransition) + { + case RetailHiddenTransition.BecameHidden: + _playTyped(entity.Id, HiddenScriptType, 1f); + if (!IsCurrent(record, entity)) + return false; + _setDirectChildrenNoDraw(record.ServerGuid, true); + if (!IsCurrent(record, entity)) + return false; + _shadows.Suspend(entity.Id); + if (!IsPlacementActive(record)) + _suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation; + // Retail CPhysicsObj::set_hidden @ 0x00514C60 calls + // CPartArray::HandleEnterWorld after hiding the object + // from its cell. Despite the name, this is the motion + // timeline boundary: it strips link animations and + // aborts every pending completion through + // MotionTableManager::HandleEnterWorld @ 0x0051BDD0. + _handlePartArrayEnterWorld(entity.Id); + if (!IsCurrent(record, entity)) + return false; + _clearInvalidTarget(record.ServerGuid); + if (!IsCurrent(record, entity)) + return false; + break; + + case RetailHiddenTransition.BecameVisible: + _playTyped(entity.Id, UnHideScriptType, 1f); + if (!IsCurrent(record, entity)) + return false; + _setDirectChildrenNoDraw(record.ServerGuid, false); + if (!IsCurrent(record, entity)) + return false; + // Retail invokes the same PartArray boundary before + // CObjCell::unhide_object restores cell visibility. + _handlePartArrayEnterWorld(entity.Id); + if (!IsCurrent(record, entity)) + return false; + bool restored = !IsPlacementActive(record) + && RestoreShadow(record, entity); + if (!IsCurrent(record, entity)) + return false; + if (restored) + _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); + break; + } } + + return IsCurrent(record, record.WorldEntity); + } + finally + { + _drainingRecords.Remove(record); } } @@ -273,9 +324,25 @@ public sealed class LiveEntityPresentationController : IDisposable private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) { - if (!visible - || record.WorldEntity is not { } entity - || (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0 + if (record.WorldEntity is not { } entity + || !IsCurrent(record, entity)) + { + return; + } + + // exit_world removes an ordinary object's collision rows on the same + // projection edge that suspends its object clock. Keep the retained + // registration so a stationary object can be restored immediately on + // hydration; it may have no later movement quantum to repair itself. + // Projectiles and active authoritative placements own their matching + // suspend/restore transaction in their dedicated controllers. + if (!visible) + { + SuspendOrdinaryShadowOutsideProjection(record, entity); + return; + } + + if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0 || IsPlacementActive(record) || !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration) || readyGeneration != record.Generation @@ -287,10 +354,40 @@ public sealed class LiveEntityPresentationController : IDisposable return; } - if (RestoreShadow(record, entity)) + bool restored = RestoreShadow(record, entity); + if (!IsCurrent(record, entity)) + return; + if (restored) _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); } + private void SuspendOrdinaryShadowOutsideProjection( + LiveEntityRecord record, + AcDream.Core.World.WorldEntity entity) + { + if (record.IsSpatiallyVisible + || record.ProjectileRuntime is not null + || IsPlacementActive(record) + || !_readyGenerationByGuid.TryGetValue( + record.ServerGuid, + out ushort readyGeneration) + || readyGeneration != record.Generation + || !_shadows.Suspend(entity.Id)) + { + return; + } + + _suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation; + } + + private bool IsCurrent( + LiveEntityRecord record, + AcDream.Core.World.WorldEntity? entity) => + entity is not null + && _liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) + && ReferenceEquals(current, record) + && ReferenceEquals(current.WorldEntity, entity); + private bool IsPlacementActive(LiveEntityRecord record) => _activePlacementGenerationByGuid.TryGetValue( record.ServerGuid, diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 53a2c0f2..a7adbd29 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -48,6 +48,7 @@ public interface ILiveEntityRemotePlacementRuntime : Vector3 LastServerPosition { get; set; } double LastServerPositionTime { get; set; } Vector3 LastShadowSyncPosition { get; set; } + Quaternion LastShadowSyncOrientation { get; set; } void HitGround(); void LeaveGround(); } @@ -111,6 +112,13 @@ public sealed class LiveEntityRecord ServerGuid = snapshot.Guid; Snapshot = snapshot; RefreshDerivedState(); + PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL; + VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL; + VelocityAuthorityVersion = snapshot.Position is null + && snapshot.Physics is null + ? 0UL + : 1UL; + MovementAuthorityVersion = 1UL; FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState; ApplyRawPhysicsState(RawPhysicsState); } @@ -124,7 +132,29 @@ public sealed class LiveEntityRecord public uint CanonicalLandblockId { get; internal set; } public uint RawPhysicsState { get; internal set; } public PhysicsStateFlags FinalPhysicsState { get; internal set; } + /// + /// Incarnation-stable retail CPhysicsObj::update_time owner. It + /// survives animation and MovementManager attachment/replacement, spatial + /// rebucketing, and appearance rehydration, and dies with this record. + /// + public RetailObjectQuantumClock ObjectClock { get; } = new(); + /// + /// Changes whenever this record leaves or re-enters the ordinary-object + /// workset. Unlike , loaded-to-loaded + /// rebucketing does not change this token. A scheduler can therefore stop a + /// catch-up batch that crossed a leave/enter boundary without rejecting an + /// ordinary same-frame cell move. + /// + internal ulong ObjectClockEpoch { get; private set; } + /// + /// True after this incarnation has successfully constructed its retail + /// CPartArray. This is an object-lifetime fact, not an animation- + /// runtime proxy: static poses and objects without a MotionTable still + /// own a PartArray and participate in the 96-unit Active gate. + /// + public bool HasPartArray { get; internal set; } public PhysicsBody? PhysicsBody { get; internal set; } + internal bool PhysicsBodyAcquisitionInProgress { get; set; } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } internal bool RequiresRemotePlacementRuntime { get; set; } @@ -134,6 +164,16 @@ public sealed class LiveEntityRecord public bool IsSpatiallyProjected { get; internal set; } public bool IsSpatiallyVisible { get; internal set; } internal ulong ProjectionMutationVersion { get; set; } + /// + /// Monotonic App operation tokens for accepted wire authority. They let a + /// handler detect a newer same-incarnation packet delivered re-entrantly + /// while an older packet is crossing presentation or spatial callbacks. + /// + internal ulong PositionAuthorityVersion { get; private set; } + internal ulong StateAuthorityVersion { get; private set; } + internal ulong VectorAuthorityVersion { get; private set; } + internal ulong VelocityAuthorityVersion { get; private set; } + internal ulong MovementAuthorityVersion { get; private set; } public bool WorldSpawnPublished { get; internal set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; } internal bool RuntimeComponentsTeardownCompleted { get; set; } @@ -145,8 +185,21 @@ public sealed class LiveEntityRecord internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) => _pendingStateTransitions.TryDequeue(out transition); + internal void SuspendObjectClock() + { + ObjectClock.Deactivate(); + ObjectClockEpoch++; + } + + internal void ResetObjectClockForEnterWorld(bool isStatic) + { + ObjectClock.ResetForEnterWorld(isStatic); + ObjectClockEpoch++; + } + internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) { + StateAuthorityVersion++; RawPhysicsState = rawState; RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply( FinalPhysicsState, @@ -158,6 +211,33 @@ public sealed class LiveEntityRecord return transition; } + internal void AdvancePositionAuthority() + { + PositionAuthorityVersion++; + VelocityAuthorityVersion++; + } + + internal void AdvanceVectorAuthority() + { + VectorAuthorityVersion++; + VelocityAuthorityVersion++; + } + + internal void AdvanceMovementAuthority() + { + MovementAuthorityVersion++; + VelocityAuthorityVersion++; + } + + internal void AdvanceCreateAuthority() + { + PositionAuthorityVersion++; + StateAuthorityVersion++; + VectorAuthorityVersion++; + VelocityAuthorityVersion++; + MovementAuthorityVersion++; + } + internal void SetChildNoDraw(bool noDraw) { FinalPhysicsState = noDraw @@ -241,6 +321,10 @@ public sealed class LiveEntityRuntime private readonly Dictionary _spatialAnimationsByLocalId = new(); private readonly Dictionary _spatialRemoteMotionByGuid = new(); private readonly Dictionary _spatialProjectilesByGuid = new(); + // Retail CPhysics::UseTime walks the ordinary object table, not a render- + // animation component table. This index is the loaded/cell-backed root + // workset; component-specific indexes remain lookup accelerators only. + private readonly Dictionary _spatialRootObjectsByGuid = new(); private bool _isClearing; private bool _sessionClearPendingFinalization; private bool _isRegisteringResources; @@ -297,6 +381,8 @@ public sealed class LiveEntityRuntime _spatialRemoteMotionByGuid; internal IReadOnlyDictionary SpatialProjectileRuntimes => _spatialProjectilesByGuid; + internal IReadOnlyDictionary SpatialRootObjects => + _spatialRootObjectsByGuid; public ParentAttachmentState ParentAttachments { get; } = new(); /// @@ -327,6 +413,7 @@ public sealed class LiveEntityRuntime { retained.Snapshot = result.Snapshot; retained.RefreshDerivedState(); + retained.AdvanceCreateAuthority(); RefreshSpatialRuntimeIndexes(retained); return new LiveEntityRegistrationResult(result, retained, false, false); } @@ -510,8 +597,18 @@ public sealed class LiveEntityRuntime record.WorldEntity!.IsAncestorDrawVisible = true; } RefreshPresentation(record); - RebucketLiveEntity(serverGuid, fullCellId); - return record.WorldEntity; + WorldEntity materialized = record.WorldEntity!; + if (!RebucketLiveEntity(serverGuid, fullCellId) + || !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + || !ReferenceEquals(current, record) + || !ReferenceEquals(current.WorldEntity, materialized)) + { + // Visibility observers may replace this GUID while rebucketing. + // A failed teardown can retain the displaced entity as a retry + // tombstone; it is not the result of this materialization. + return null; + } + return materialized; } /// Changes spatial buckets only. No logical resource callbacks run. @@ -523,6 +620,10 @@ public sealed class LiveEntityRuntime bool wasProjected = record.IsSpatiallyProjected; bool wasVisible = record.IsSpatiallyVisible; + bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue( + serverGuid, + out LiveEntityRecord? indexedRoot) + && ReferenceEquals(indexedRoot, record); ulong projectionOperation = ++record.ProjectionMutationVersion; // GpuWorldState reports an intermediate false/true pair while moving // between two loaded buckets. Suppress those implementation details @@ -570,6 +671,38 @@ public sealed class LiveEntityRuntime record.CanonicalLandblockId = spatialCellOrLandblockId == 0 ? 0u : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; + bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World + && record.IsSpatiallyProjected + && record.IsSpatiallyVisible + && record.FullCellId != 0; + // prepare_to_enter_world (0x00511FA0) writes update_time = cur_time. + // Only a root-workset membership edge rebases the clock. Ordinary + // loaded-to-loaded movement preserves it; parented/attached objects + // take retail update_object's parent early-out and remain suspended. + if (!wasProjected && !isOrdinaryRoot) + { + record.SuspendObjectClock(); + SynchronizePhysicsBodyActiveState(record); + } + else if (wasOrdinaryRoot != isOrdinaryRoot) + { + if (isOrdinaryRoot) + { + // InitObjectBegin and every leave-visibility edge are inactive + // before retail prepare_to_enter_world. Preserve that + // prerequisite for a first materialization as well. + if (!wasProjected) + record.ObjectClock.Deactivate(); + record.ResetObjectClockForEnterWorld( + (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0); + SynchronizePhysicsBodyActiveState(record); + } + else + { + record.SuspendObjectClock(); + SynchronizePhysicsBodyActiveState(record); + } + } RefreshSpatialRuntimeIndexes(record); Exception? runtimeNotificationFailure = null; if (!wasProjected || wasVisible != visible) @@ -583,6 +716,18 @@ public sealed class LiveEntityRuntime runtimeNotificationFailure = error; } } + // Final observers are intentionally re-entrant. A callback can delete + // and recreate this GUID or start a newer rebucket on the same record; + // the outer caller must not continue with captures from that displaced + // projection operation. + if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure); + return false; + } ThrowAfterCommittedProjectionChange( serverGuid, spatialNotificationFailure, @@ -600,6 +745,11 @@ public sealed class LiveEntityRuntime || record.WorldEntity is null) return false; + bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue( + serverGuid, + out LiveEntityRecord? rootRecord) + && ReferenceEquals(rootRecord, record); + ulong objectClockEpoch = record.ObjectClockEpoch; ulong projectionOperation = ++record.ProjectionMutationVersion; Exception? spatialNotificationFailure = null; try @@ -631,6 +781,14 @@ public sealed class LiveEntityRuntime _visibleWorldEntitiesByGuid.Remove(serverGuid); record.IsSpatiallyProjected = false; record.IsSpatiallyVisible = false; + // GpuWorldState normally publishes the leave edge synchronously and + // OnSpatialVisibilityChanged suspends this exact clock there. During a + // reentrant notification the edge is deferred, so commit the missing + // suspension here. One root-workset leave is one epoch edge -- never + // advance it once in the observer and again in this owner. + if (wasOrdinaryRoot && record.ObjectClockEpoch == objectClockEpoch) + record.SuspendObjectClock(); + SynchronizePhysicsBodyActiveState(record); RefreshSpatialRuntimeIndexes(record); Exception? runtimeNotificationFailure = null; if (spatialEdgeWasDeferred) @@ -644,6 +802,18 @@ public sealed class LiveEntityRuntime runtimeNotificationFailure = error; } } + // The manually published deferred edge is an arbitrary callback + // boundary just like Rebucket's final visibility edge. It may + // reproject this record or replace the GUID; the displaced outer + // withdrawal must not report itself as the current operation. + if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure); + return false; + } ThrowAfterCommittedProjectionChange( serverGuid, spatialNotificationFailure, @@ -845,11 +1015,70 @@ public sealed class LiveEntityRuntime return true; } + /// + /// Acquires the one retail CPhysicsObj body for this exact live + /// incarnation. Static animation may need the body before a MovementManager + /// or projectile component exists; later components must adopt this same + /// instance instead of replaying CreateObject vector initialization. + /// + internal PhysicsBody GetOrCreatePhysicsBody( + uint serverGuid, + Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is null) + { + throw new InvalidOperationException( + $"Cannot acquire physics body before live entity 0x{serverGuid:X8} is materialized."); + } + if (record.PhysicsBody is { } retained) + return retained; + if (record.PhysicsBodyAcquisitionInProgress) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} physics-body acquisition is already in progress."); + } + + record.PhysicsBodyAcquisitionInProgress = true; + try + { + PhysicsBody candidate = factory(record) + ?? throw new InvalidOperationException("Physics-body factory returned null."); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + || !ReferenceEquals(current, record) + || current.WorldEntity is null) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} changed incarnation during physics-body acquisition."); + } + if (record.PhysicsBody is { } concurrentlyBound) + { + if (ReferenceEquals(concurrentlyBound, candidate)) + return concurrentlyBound; + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} acquired two physics bodies within one incarnation."); + } + + record.PhysicsBody = candidate; + candidate.State = record.FinalPhysicsState; + SynchronizePhysicsBodyActiveState(record); + return candidate; + } + finally + { + record.PhysicsBodyAcquisitionInProgress = false; + } + } + public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); + if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null) + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition."); PhysicsBody candidateBody = runtime.Body; if (record.PhysicsBody is { } canonicalBody && !ReferenceEquals(canonicalBody, candidateBody)) @@ -868,6 +1097,7 @@ public sealed class LiveEntityRuntime record.RemoteMotionRuntime = runtime; record.PhysicsBody = candidateBody; candidateBody.State = record.FinalPhysicsState; + SynchronizePhysicsBodyActiveState(record); if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) { // Capture the incarnation, not only its GUID. A callback retained @@ -919,6 +1149,9 @@ public sealed class LiveEntityRuntime throw new InvalidOperationException( $"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized."); } + if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null) + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} cannot bind projectile physics during physics-body acquisition."); PhysicsBody candidateBody = runtime.Body; if (record.PhysicsBody is { } canonicalBody && !ReferenceEquals(canonicalBody, candidateBody)) @@ -930,6 +1163,7 @@ public sealed class LiveEntityRuntime record.ProjectileRuntime = runtime; record.PhysicsBody = candidateBody; candidateBody.State = record.FinalPhysicsState; + SynchronizePhysicsBodyActiveState(record); RefreshSpatialRuntimeIndexes(record); } @@ -999,6 +1233,8 @@ public sealed class LiveEntityRuntime if (applied) { RefreshRecord(update.Guid, accepted); + if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) + record.AdvancePositionAuthority(); ClearWorldCell(update.Guid); ParentAttachments.EndChildProjection(update.Guid); } @@ -1011,6 +1247,8 @@ public sealed class LiveEntityRuntime if (applied) { RefreshRecord(update.ChildGuid, accepted); + if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record)) + record.AdvancePositionAuthority(); ClearWorldCell(update.ChildGuid); } return applied; @@ -1022,6 +1260,8 @@ public sealed class LiveEntityRuntime if (applied) { RefreshRecord(update.ChildGuid, accepted); + if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record)) + record.AdvancePositionAuthority(); ClearWorldCell(update.ChildGuid); } return applied; @@ -1036,13 +1276,24 @@ public sealed class LiveEntityRuntime bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps); if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) RefreshRecord(update.Guid, snapshot); + if (applied + && retainPayload + && _recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) + { + record.AdvanceMovementAuthority(); + } return applied; } public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted) { bool applied = _inbound.TryApplyVector(update, out accepted); - if (applied) RefreshRecord(update.Guid, accepted); + if (applied) + { + RefreshRecord(update.Guid, accepted); + if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) + record.AdvanceVectorAuthority(); + } return applied; } @@ -1117,6 +1368,13 @@ public sealed class LiveEntityRuntime RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition); if (acceptedPosition) { + if (_recordsByGuid.TryGetValue( + update.Guid, + out LiveEntityRecord? acceptedRecord) + && ReferenceEquals(acceptedRecord, beforePosition)) + { + acceptedRecord.AdvancePositionAuthority(); + } ParentAttachments.EndChildProjection(update.Guid); } } @@ -1136,12 +1394,231 @@ public sealed class LiveEntityRuntime /// script/effect owners but clears the cell and withdraws the root /// projection until a fresh Position re-enters it. /// + public RetailObjectClockDisposition GetRootObjectClockDisposition( + uint serverGuid) + { + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.ProjectionKind is not LiveEntityProjectionKind.World + || !record.IsSpatiallyProjected + || !record.IsSpatiallyVisible + || record.FullCellId == 0) + { + return RetailObjectClockDisposition.Suspend; + } + + return (record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0 + ? RetailObjectClockDisposition.Suspend + : RetailObjectClockDisposition.Advance; + } + public bool ShouldAdvanceRootRuntime(uint serverGuid) => - _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) - && record.IsSpatiallyProjected - && record.IsSpatiallyVisible - && record.FullCellId != 0 - && (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0; + GetRootObjectClockDisposition(serverGuid) + is RetailObjectClockDisposition.Advance; + + internal bool IsCurrentPositionAuthority( + LiveEntityRecord record, + ulong authorityVersion) => + _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, record) + && current.PositionAuthorityVersion == authorityVersion; + + internal bool IsCurrentStateAuthority( + LiveEntityRecord record, + ulong authorityVersion) => + _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, record) + && current.StateAuthorityVersion == authorityVersion; + + internal bool IsCurrentVectorAuthority( + LiveEntityRecord record, + ulong authorityVersion) => + _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, record) + && current.VectorAuthorityVersion == authorityVersion; + + internal bool IsCurrentVelocityAuthority( + LiveEntityRecord record, + ulong authorityVersion) => + _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, record) + && current.VelocityAuthorityVersion == authorityVersion; + + internal bool IsCurrentMovementAuthority( + LiveEntityRecord record, + ulong authorityVersion) => + _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, record) + && current.MovementAuthorityVersion == authorityVersion; + + /// + /// Copies the canonical loaded root-object workset used by retail + /// CPhysics::UseTime. The record reference is the incarnation token; + /// callers must revalidate it after callbacks that can delete or rebucket. + /// + internal void CopySpatialRootObjectRecordsTo(List destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + foreach ((uint serverGuid, LiveEntityRecord indexed) in _spatialRootObjectsByGuid) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + && ReferenceEquals(current, indexed) + && HasSpatialRuntimeProjection(current)) + { + destination.Add(current); + } + } + } + + internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) => + IsCurrentRecord(record) + && _spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexed) + && ReferenceEquals(indexed, record) + && HasSpatialRuntimeProjection(record); + + internal bool IsCurrentSpatialAnimation( + LiveEntityRecord record, + ILiveEntityAnimationRuntime animation) => + IsCurrentSpatialRootObject(record) + && ReferenceEquals(record.AnimationRuntime, animation) + && record.WorldEntity is { } entity + && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed) + && ReferenceEquals(indexed, animation); + + /// + /// Copies the currently spatial animation owners through the concrete + /// dictionary enumerator. This keeps per-frame traversal allocation-free; + /// exposing the workset as + /// would box its enumerator at a hot call site. + /// + internal void CopySpatialAnimationRuntimesTo( + List> destination) + where TAnimation : class, ILiveEntityAnimationRuntime + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + foreach ((uint localId, ILiveEntityAnimationRuntime animation) + in _spatialAnimationsByLocalId) + { + if (animation is TAnimation typed) + { + destination.Add( + new KeyValuePair(localId, typed)); + } + } + } + + internal void CopySpatialAnimationLocalIdsTo(HashSet destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + foreach (uint localId in _spatialAnimationsByLocalId.Keys) + destination.Add(localId); + } + + /// + /// Host side of retail CPhysicsObj::set_active(1) for a movement + /// manager owned by this exact incarnation. Static is a required no-op. + /// + internal bool TryActivateOrdinaryObject( + uint serverGuid, + ILiveEntityRemoteMotionRuntime runtime) + { + ArgumentNullException.ThrowIfNull(runtime); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || !ReferenceEquals(record.RemoteMotionRuntime, runtime) + || (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0) + { + return false; + } + + record.ObjectClock.Activate(); + runtime.Body.TransientState |= TransientStateFlags.Active; + return true; + } + + /// + /// Commits retail CPhysicsObj::set_velocity (0x005113F0) to the + /// canonical body for this exact incarnation. The body and the retained + /// object clock are one CPhysicsObj: a non-Static inactive-to-active edge + /// rebases both together, while Static preserves its prior Active state. + /// + internal bool TryCommitAuthoritativeVelocity( + LiveEntityRecord record, + PhysicsBody body, + Vector3 velocity, + double currentTime) => + TryCommitAuthoritativeVectorCore( + record, + body, + velocity, + angularVelocity: null, + currentTime); + + /// + /// Commits the paired retail VectorUpdate writes + /// (set_velocity, then set_omega) to one canonical body. + /// Validation is atomic: malformed wire vectors cannot partially mutate + /// the live object. + /// + internal bool TryCommitAuthoritativeVector( + LiveEntityRecord record, + PhysicsBody body, + Vector3 velocity, + Vector3 angularVelocity, + double currentTime) => + TryCommitAuthoritativeVectorCore( + record, + body, + velocity, + angularVelocity, + currentTime); + + private bool TryCommitAuthoritativeVectorCore( + LiveEntityRecord record, + PhysicsBody body, + Vector3 velocity, + Vector3? angularVelocity, + double currentTime) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(body); + if (!IsFinite(velocity) + || (angularVelocity is { } omega && !IsFinite(omega)) + || !double.IsFinite(currentTime) + || !_recordsByGuid.TryGetValue( + record.ServerGuid, + out LiveEntityRecord? current) + || !ReferenceEquals(current, record) + || !ReferenceEquals(current.PhysicsBody, body)) + { + return false; + } + + bool wasBodyActive = + (body.TransientState & TransientStateFlags.Active) != 0; + body.set_velocity(velocity); + if ((current.FinalPhysicsState & PhysicsStateFlags.Static) != 0) + { + // PhysicsBody models set_velocity's leaf write and therefore sets + // Active unconditionally. Retail CPhysicsObj::set_active(1) is a + // no-op for Static, so restore the exact prior bit here. + if (!wasBodyActive) + body.TransientState &= ~TransientStateFlags.Active; + } + else + { + bool clockReactivated = current.ObjectClock.Activate(); + // PhysicsBody.LastUpdateTime remains only the compatibility clock + // for older absolute-time helpers; the record clock is canonical. + if (clockReactivated || !wasBodyActive) + body.LastUpdateTime = currentTime; + } + + if (angularVelocity is { } acceptedOmega) + body.Omega = acceptedOmega; + return true; + } /// /// Copies the currently spatial remote-motion owners into caller-owned @@ -1393,6 +1870,19 @@ public sealed class LiveEntityRuntime { if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record)) return; + bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue( + guid, + out LiveEntityRecord? indexedRoot) + && ReferenceEquals(indexedRoot, record); + // Pickup/Parent is retail's leave-world edge. Suspend the canonical + // object clock while the record is still indexed as a root; clearing + // FullCellId first removes that evidence and makes the later projection + // withdrawal look like a duplicate non-root edge. + if (wasOrdinaryRoot) + { + record.SuspendObjectClock(); + SynchronizePhysicsBodyActiveState(record); + } record.FullCellId = 0; record.CanonicalLandblockId = 0; RefreshSpatialRuntimeIndexes(record); @@ -1404,6 +1894,7 @@ public sealed class LiveEntityRuntime private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) => record.WorldEntity is not null + && record.ProjectionKind is LiveEntityProjectionKind.World && record.IsSpatiallyProjected && record.IsSpatiallyVisible && record.FullCellId != 0; @@ -1413,6 +1904,17 @@ public sealed class LiveEntityRuntime bool current = IsCurrentRecord(record); bool spatial = current && HasSpatialRuntimeProjection(record); + if (spatial) + { + _spatialRootObjectsByGuid[record.ServerGuid] = record; + } + else if (current + || (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot) + && ReferenceEquals(indexedRoot, record))) + { + _spatialRootObjectsByGuid.Remove(record.ServerGuid); + } + if (record.WorldEntity is { } entity) { if (spatial && record.AnimationRuntime is { } animation) @@ -1455,6 +1957,12 @@ public sealed class LiveEntityRuntime private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record) { + if (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot) + && ReferenceEquals(indexedRoot, record)) + { + _spatialRootObjectsByGuid.Remove(record.ServerGuid); + } + if (record.WorldEntity is { } entity && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation) && ReferenceEquals(indexedAnimation, record.AnimationRuntime)) @@ -1506,13 +2014,46 @@ public sealed class LiveEntityRuntime return; bool wasVisible = record.IsSpatiallyVisible; + bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue( + serverGuid, + out LiveEntityRecord? indexedRoot) + && ReferenceEquals(indexedRoot, record); record.IsSpatiallyVisible = visible; + bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World + && record.IsSpatiallyProjected + && visible + && record.FullCellId != 0; + if (_rebucketingGuid != serverGuid && wasOrdinaryRoot != isOrdinaryRoot) + { + if (isOrdinaryRoot) + record.ResetObjectClockForEnterWorld( + (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0); + else + record.SuspendObjectClock(); + SynchronizePhysicsBodyActiveState(record); + } RefreshSpatialRuntimeIndexes(record); RefreshPresentation(record); if (_rebucketingGuid != serverGuid && wasVisible != visible) PublishProjectionVisibilityChanged(record, visible); } + private static void SynchronizePhysicsBodyActiveState( + LiveEntityRecord record) + { + if (record.PhysicsBody is not { } body) + return; + if (record.ObjectClock.IsActive) + body.TransientState |= TransientStateFlags.Active; + else + body.TransientState &= ~TransientStateFlags.Active; + } + + private static bool IsFinite(Vector3 value) => + float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); + private void PublishProjectionVisibilityChanged(LiveEntityRecord record, bool visible) { Delegate[] subscribers = ProjectionVisibilityChanged?.GetInvocationList() @@ -1617,6 +2158,7 @@ public sealed class LiveEntityRuntime _spatialAnimationsByLocalId.Clear(); _spatialRemoteMotionByGuid.Clear(); _spatialProjectilesByGuid.Clear(); + _spatialRootObjectsByGuid.Clear(); ParentAttachments.Clear(); _inbound.Clear(); _spatial.ClearLiveEntityLifetimeState(); diff --git a/src/AcDream.App/World/LiveEntityRuntimeViews.cs b/src/AcDream.App/World/LiveEntityRuntimeViews.cs index 2aa5f40b..5d6620e0 100644 --- a/src/AcDream.App/World/LiveEntityRuntimeViews.cs +++ b/src/AcDream.App/World/LiveEntityRuntimeViews.cs @@ -52,21 +52,25 @@ public sealed class LiveEntityAnimationRuntimeView && runtime.ClearAnimationRuntime(guid); } + /// Copies only currently resident local IDs without boxing a + /// dictionary-key enumerator. + public void CopySpatialIdsTo(HashSet destination) + { + ArgumentNullException.ThrowIfNull(destination); + LiveEntityRuntime? runtime = _runtime(); + if (runtime is null) + destination.Clear(); + else + runtime.CopySpatialAnimationLocalIdsTo(destination); + } + public Enumerator GetEnumerator() { - _iterationSnapshot.Clear(); LiveEntityRuntime? runtime = _runtime(); - if (runtime is not null) - { - foreach (var pair in runtime.SpatialAnimationRuntimes) - { - if (pair.Value is TAnimation typed) - { - _iterationSnapshot.Add( - new KeyValuePair(pair.Key, typed)); - } - } - } + if (runtime is null) + _iterationSnapshot.Clear(); + else + runtime.CopySpatialAnimationRuntimesTo(_iterationSnapshot); return new Enumerator(runtime, _iterationSnapshot); } diff --git a/src/AcDream.App/World/RetailInboundEventDispatcher.cs b/src/AcDream.App/World/RetailInboundEventDispatcher.cs new file mode 100644 index 00000000..16f054dd --- /dev/null +++ b/src/AcDream.App/World/RetailInboundEventDispatcher.cs @@ -0,0 +1,123 @@ +namespace AcDream.App.World; + +/// +/// Serializes retail network/event mutations on the update thread. +/// +/// +/// Retail SmartBox dispatch and CPhysics::UseTime never interleave two packet +/// bodies on one CPhysicsObj call stack. App callbacks are synchronous, so an +/// observer can otherwise re-enter a session event while an older packet or +/// object quantum is still unwinding. Nested work is retained in arrival +/// order and drained only after the current operation reaches its complete +/// retail tail. This preserves the protocol's independent timestamp channels: +/// an accepted State never cancels an accepted Position merely because their +/// callbacks touched the same object. +/// +internal sealed class RetailInboundEventDispatcher +{ + private interface IPendingOperation + { + void Invoke(); + } + + private sealed class PendingAction(Action operation) : IPendingOperation + { + public void Invoke() => operation(); + } + + private sealed class PendingStateOperation( + TReceiver receiver, + TState state, + Action operation) : IPendingOperation + { + public void Invoke() => operation(receiver, state); + } + + private readonly Queue _pending = new(); + private bool _isDraining; + + internal int PendingCount => _pending.Count; + internal bool IsDraining => _isDraining; + + internal void Run(Action operation) + { + ArgumentNullException.ThrowIfNull(operation); + if (_isDraining) + { + _pending.Enqueue(new PendingAction(operation)); + return; + } + + _isDraining = true; + try + { + operation(); + DrainPending(); + } + catch + { + // A failed packet cannot leave later packets stranded for an + // unrelated future frame, or replay them after partial teardown. + _pending.Clear(); + throw; + } + finally + { + _isDraining = false; + } + } + + /// + /// Allocation-free non-reentrant dispatch for hot frame and packet paths. + /// A heterogeneous wrapper is created only when an operation genuinely + /// re-enters the active retail FIFO and therefore must outlive this call. + /// + internal void Run( + TReceiver receiver, + TState state, + Action operation) + { + ArgumentNullException.ThrowIfNull(operation); + if (_isDraining) + { + _pending.Enqueue( + new PendingStateOperation( + receiver, + state, + operation)); + return; + } + + _isDraining = true; + try + { + operation(receiver, state); + DrainPending(); + } + catch + { + _pending.Clear(); + throw; + } + finally + { + _isDraining = false; + } + } + + private void DrainPending() + { + while (_pending.TryDequeue(out IPendingOperation? next)) + next.Invoke(); + } + + internal void Clear() + { + if (_isDraining) + { + throw new InvalidOperationException( + "Inbound event work cannot be cleared while its retail FIFO is active."); + } + _pending.Clear(); + } +} diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 15fc1fa0..b8abad35 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -142,15 +142,16 @@ public sealed class AnimationSequencer /// /// Crucially this is **not** per-node: while a link animation plays, the /// surfaced velocity is still the cycle's velocity (the cycle was added - /// last, so SetVelocity's latest call wins). Remote entity dead-reckoning - /// reads this to integrate position without gapping during stance - /// transitions. + /// last, so SetVelocity's latest call wins). CSequence::apply_physics + /// contributes it directly to the complete root Frame; no separate body- + /// velocity reconstruction is performed from this accessor. /// /// public Vector3 CurrentVelocity => _core.Velocity; /// - /// Sequence-wide omega, matching 's semantics. + /// Sequence-wide omega. CSequence::apply_physics rotates the same + /// complete root Frame that carries PosFrames and sequence velocity. /// public Vector3 CurrentOmega => _core.Omega; @@ -283,6 +284,12 @@ public sealed class AnimationSequencer _table = new CMotionTable(motionTable); _state = new MotionState(); _manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this)); + + // CPartArray::InitDefaults (0x00518980) runs for every setup-backed + // PartArray, before CPhysicsObj::InitDefaults installs its motion + // table. Static is only a later workset-membership decision; it does + // not gate installation of Setup.DefaultAnimation itself. + InitializeSetupDefaultAnimation((uint)setup.DefaultAnimation); } /// @@ -399,38 +406,9 @@ public sealed class AnimationSequencer if (dispatchResult != MotionTableManagerError.Success) return; - // ── Synthesize CurrentOmega for turn cycles ─────────────────────── - // Humanoid turn MotionData often ships without HasOmega. Until the - // remaining R6 rotation path consumes CSequence's complete Frame - // orientation, the remote ObservedOmega seam needs the retail - // turn-rate fallback. Decompile references: - // FUN_00529210 apply_current_movement (writes Omega) - // chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1) - // The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left - // turns (holtburger confirms the same via motion_resolution.rs). - if (_core.Omega.LengthSquared() < 1e-9f) - { - float zomega = 0f; - uint low = motion & 0xFFu; - switch (low) - { - case 0x0D: // TurnRight — clockwise from above = -Z in right-handed. - zomega = -(MathF.PI / 2f) * adjustedSpeed; - break; - case 0x0E: // TurnLeft — counter-clockwise = +Z. - // adjust_motion above ALREADY remapped 0x0E → 0x0D - // with adjustedSpeed = -speedMod, so the same - // formula as 0x0D applied to the negated speed - // produces the correct +Z (CCW) result. Using a - // different sign here would double-negate and - // animate a left turn as a right turn — that was - // the bug observed before this fix (commit follows). - zomega = -(MathF.PI / 2f) * adjustedSpeed; - break; - } - if (zomega != 0f) - _core.SetOmega(new Vector3(0f, 0f, zomega)); - } + // Rotation stays DAT-authored. Retail MotionData::add_motion + // (0x005224B0) contributes the entry's literal omega to CSequence; + // CSequence::apply_physics emits it through the complete Frame. } /// @@ -453,6 +431,36 @@ public sealed class AnimationSequencer /// public void InitializeState() => EnsureInitialized(); + /// + /// Retail CPartArray::InitDefaults (0x00518980): a Setup + /// DefaultAnimation bypasses the MotionTable, clears the sequence, + /// and appends one direct animation over frames 0..-1 at 30 fps. + /// Installation is unconditional for every setup-backed PartArray. + /// CPhysicsObj::InitDefaults separately decides whether a Static + /// owner enters CPhysics::static_animating_objects; ordinary motion + /// table initialization may subsequently replace this direct sequence. + /// + public bool InitializeSetupDefaultAnimation(uint animationId) + { + if (animationId == 0) + return false; + + // CPartArray::InitDefaults (0x00518980) calls only + // CSequence::clear_animations (0x00524DC0). Sequence velocity, + // omega, and placement state belong to the surrounding PartArray + // lifetime and survive replacement of the animation list. + _core.ClearAnimations(); + _pendingHooks.Clear(); + _core.AppendAnimation(new AnimData + { + AnimId = (QualifiedDataId)animationId, + LowFrame = 0, + HighFrame = -1, + Framerate = 30f, + }); + return _core.CurrAnim is not null; + } + /// /// R2-Q5: the single dispatch entry — lazy initialize_state, then /// . The resulting diff --git a/src/AcDream.Core/Physics/InterpolationManager.cs b/src/AcDream.Core/Physics/InterpolationManager.cs index aea05d3c..9f7f192f 100644 --- a/src/AcDream.Core/Physics/InterpolationManager.cs +++ b/src/AcDream.Core/Physics/InterpolationManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.Core.Physics.Motion; namespace AcDream.Core.Physics; @@ -15,15 +16,9 @@ namespace AcDream.Core.Physics; // InterpolationManager::NodeCompleted acclient @ 0x005559A0 // InterpolationManager::StopInterpolating acclient @ 0x00555950 // -// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes -// current body position + max-speed from the motion table; we return the -// world-space delta vector to apply to the body for this frame. -// -// Public C# API kept Vector3-based for compatibility with PositionManager and -// GameWindow callsites; retail-spec method names are documented inline. The -// retail Frame mutation pattern collapses to "return a Vector3 delta" because -// adjust_offset's offset Frame is rotation-zero (translation-only) for this -// queue's purposes — see audit 04-interp-manager.md § 4. +// FIFO Position-waypoint queue (cap 20). The compatibility overload returns +// only its world-space origin, while the production overload carries retail's +// complete relative Frame from Position::subtract2, including orientation. // // Bug fixes applied vs prior port (audit § 7): // #1: progress_quantum accumulates dt (not step magnitude). @@ -38,8 +33,7 @@ namespace AcDream.Core.Physics; internal sealed class InterpolationNode { public Vector3 TargetPosition; - public float Heading; - public bool IsHeadingValid; + public Quaternion TargetOrientation = Quaternion.Identity; } /// @@ -120,6 +114,7 @@ public sealed class InterpolationManager private float _progressQuantum = 0f; // progress_quantum (sum of dt) private float _originalDistance = OriginalDistanceSentinel; // original_distance private int _failCount = 0; // node_fail_counter + private bool _keepHeading; // keep_heading // ── public API ──────────────────────────────────────────────────────────── @@ -167,11 +162,31 @@ public sealed class InterpolationManager /// Pass null if not available — far/near classification falls back /// to "near" (no pre-armed blip). /// - public void Enqueue( + public Quaternion? Enqueue( Vector3 targetPosition, float heading, bool isMovingTo, Vector3? currentBodyPosition = null) + => Enqueue( + targetPosition, + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, heading), + isMovingTo, + currentBodyPosition, + currentBodyOrientation: null); + + /// + /// Complete-frame overload of retail InterpolateTo. The node keeps + /// the target quaternion; a near enqueue assigns + /// to retail's manager-wide + /// keep_heading flag. The far branch deliberately retains the + /// manager's prior flag, matching the retail early return. + /// + public Quaternion? Enqueue( + Vector3 targetPosition, + Quaternion targetOrientation, + bool isMovingTo, + Vector3? currentBodyPosition = null, + Quaternion? currentBodyOrientation = null) { // Retail compares dist against either the tail's stored position // (if tail exists AND tail->type == 1) or the body's m_position. @@ -195,10 +210,17 @@ public sealed class InterpolationManager // Far branch (retail line 352918, dist > GetAutonomyBlipDistance): if (dist > AutonomyBlipDistance) { - EnqueueRaw(targetPosition, heading, isMovingTo); + // The far branch does not assign keep_heading from arg3. It uses + // the manager's existing flag when storing this Position. + EnqueueRaw( + targetPosition, + StoreTargetOrientation( + targetOrientation, + currentBodyOrientation, + _keepHeading)); // Pre-arm immediate blip on next AdjustOffset (audit § 7 #3). _failCount = StallFailCountThreshold + 1; - return; + return null; } // Near & already-close branch (retail line 352962): @@ -209,7 +231,14 @@ public sealed class InterpolationManager if (bodyDist <= DesiredDistance) { Clear(); - return; + // InterpolateTo 0x00555C08 calls CPhysicsObj::set_heading + // with the target Frame's heading. It does not install the + // target's pitch/roll at this already-close seam. + return isMovingTo + ? null + : MoveToMath.SetHeading( + targetOrientation, + MoveToMath.GetHeading(targetOrientation)); } } @@ -227,19 +256,43 @@ public sealed class InterpolationManager _queue.RemoveFirst(); // 3. Append. - EnqueueRaw(targetPosition, heading, isMovingTo); + _keepHeading = isMovingTo; + EnqueueRaw( + targetPosition, + StoreTargetOrientation( + targetOrientation, + currentBodyOrientation, + _keepHeading)); + return null; } - private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo) + private void EnqueueRaw( + Vector3 target, + Quaternion targetOrientation) { _queue.AddLast(new InterpolationNode { TargetPosition = target, - Heading = heading, - IsHeadingValid = isMovingTo, + TargetOrientation = targetOrientation, }); } + private static Quaternion StoreTargetOrientation( + Quaternion targetOrientation, + Quaternion? currentBodyOrientation, + bool keepHeading) + { + if (!keepHeading || currentBodyOrientation is not { } current) + return targetOrientation; + + // InterpolateTo stores the object's current heading into the node + // when keep_heading is active. Frame::set_heading intentionally + // discards the target Position's pitch/roll at this seam. + return MoveToMath.SetHeading( + targetOrientation, + MoveToMath.GetHeading(current)); + } + /// /// Compute the per-frame world-space correction delta. Combines the retail /// UseTime blip-check (fail_count > 3 → snap to tail, clear queue) @@ -259,14 +312,74 @@ public sealed class InterpolationManager /// Max motion-table speed for this entity's current cycle (m/s). /// Pass 0 to use the fallback. /// - public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp) + public Vector3 AdjustOffset( + double dt, + Vector3 currentBodyPosition, + float maxSpeedFromMinterp, + bool inContact = true) + { + if (!inContact) + return Vector3.Zero; + + InterpolationStep step = ComputeStep( + dt, + currentBodyPosition, + maxSpeedFromMinterp); + return step.Overwrites ? step.WorldOrigin : Vector3.Zero; + } + + /// + /// Retail InterpolationManager::adjust_offset complete-Frame path + /// (0x00555D30). When interpolation is active it replaces both components + /// of with Position::subtract2's relative + /// target frame, then scales only Origin to the catch-up step. A MoveTo + /// node keeps heading by replacing the relative rotation with identity. + /// When the queue is empty or a node completes, the incoming PartArray + /// frame remains untouched. + /// + public bool AdjustOffset( + double dt, + Vector3 currentBodyPosition, + Quaternion currentBodyOrientation, + float maxSpeedFromMinterp, + MotionDeltaFrame offset, + bool inContact = true) + { + ArgumentNullException.ThrowIfNull(offset); + if (!inContact) + return false; + + InterpolationStep step = ComputeStep( + dt, + currentBodyPosition, + maxSpeedFromMinterp); + if (!step.Overwrites) + return false; + + offset.Origin = MoveToMath.GlobalToLocalVec( + currentBodyOrientation, + step.WorldOrigin); + offset.Orientation = _keepHeading + ? Quaternion.Identity + : FrameOps.SetRotate( + offset.Origin, + Quaternion.Identity, + Quaternion.Inverse(currentBodyOrientation) + * step.TargetOrientation); + return true; + } + + private InterpolationStep ComputeStep( + double dt, + Vector3 currentBodyPosition, + float maxSpeedFromMinterp) { // dt sanity guard — protects PhysicsBody.Position from NaN poisoning. if (dt <= 0 || double.IsNaN(dt)) - return Vector3.Zero; + return default; if (_queue.First is null) - return Vector3.Zero; + return default; // Distance to head node (retail line 353083). var head = _queue.First.Value; @@ -278,7 +391,7 @@ public sealed class InterpolationManager if (dist <= DesiredDistance) { NodeCompleted(popHead: true, currentBodyPosition); - return Vector3.Zero; + return default; } // Catch-up speed (retail line 353122 + 353128 fallback). @@ -344,9 +457,13 @@ public sealed class InterpolationManager // Retail splits this into a separate UseTime call; we collapse it. if (_failCount > StallFailCountThreshold) { - Vector3 tailPos = _queue.Last!.Value.TargetPosition; + InterpolationNode tail = _queue.Last!.Value; + Vector3 tailDelta = tail.TargetPosition - currentBodyPosition; Clear(); - return tailPos - currentBodyPosition; + return new InterpolationStep( + true, + tailDelta, + tail.TargetOrientation); } // Per-frame step magnitude (retail line 353218). @@ -358,9 +475,17 @@ public sealed class InterpolationManager // Direction × step. Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step; - return delta; + return new InterpolationStep( + true, + delta, + head.TargetOrientation); } + private readonly record struct InterpolationStep( + bool Overwrites, + Vector3 WorldOrigin, + Quaternion TargetOrientation); + /// /// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached; /// popHead=false during stall fail (re-baseline only). For our collapsed diff --git a/src/AcDream.Core/Physics/Motion/FrameOps.cs b/src/AcDream.Core/Physics/Motion/FrameOps.cs index a36a1292..1d40acee 100644 --- a/src/AcDream.Core/Physics/Motion/FrameOps.cs +++ b/src/AcDream.Core/Physics/Motion/FrameOps.cs @@ -25,6 +25,48 @@ public static class FrameOps /// square against |v|². public const float FEpsilon = 0.000199999995f; + /// + /// Retail Frame::set_rotate (0x00535080). The candidate is + /// normalized in extended precision, then the complete frame is checked + /// by Frame::IsValid (0x00534ED0). If that check fails, retail + /// restores the previous quaternion instead of allowing NaNs to poison + /// the live object transform. + /// + public static Quaternion SetRotate( + Vector3 frameOrigin, + Quaternion previous, + Quaternion candidate) + { + double lengthSquared = + ((double)candidate.W * candidate.W) + + ((double)candidate.X * candidate.X) + + ((double)candidate.Y * candidate.Y) + + ((double)candidate.Z * candidate.Z); + double inverseLength = 1.0 / Math.Sqrt(lengthSquared); + var normalized = new Quaternion( + (float)(candidate.X * inverseLength), + (float)(candidate.Y * inverseLength), + (float)(candidate.Z * inverseLength), + (float)(candidate.W * inverseLength)); + + if (float.IsNaN(frameOrigin.X) + || float.IsNaN(frameOrigin.Y) + || float.IsNaN(frameOrigin.Z) + || float.IsNaN(normalized.W) + || float.IsNaN(normalized.X) + || float.IsNaN(normalized.Y) + || float.IsNaN(normalized.Z)) + { + return previous; + } + + float normalizedLengthSquared = normalized.LengthSquared(); + return !float.IsNaN(normalizedLengthSquared) + && MathF.Abs(normalizedLengthSquared - 1f) < FEpsilon * 5f + ? normalized + : previous; + } + /// Frame::grotate — incremental WORLD-space rotation. public static void GRotate(Frame frame, Vector3 rotationGlobal) { @@ -48,7 +90,10 @@ public static class FrameOps rotationGlobal.Y * s * invMag, rotationGlobal.Z * s * invMag, c); - frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation)); + frame.Orientation = SetRotate( + frame.Origin, + frame.Orientation, + Quaternion.Multiply(r, frame.Orientation)); } /// Frame::rotate — LOCAL rotation vector, mapped to @@ -65,7 +110,10 @@ public static class FrameOps public static void Combine(Frame frame, Frame pos) { frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation); - frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation); + frame.Orientation = SetRotate( + frame.Origin, + frame.Orientation, + frame.Orientation * pos.Orientation); } /// @@ -75,7 +123,9 @@ public static class FrameOps /// public static void Subtract1(Frame frame, Frame pos) { - frame.Orientation = Quaternion.Normalize( + frame.Orientation = SetRotate( + frame.Origin, + frame.Orientation, frame.Orientation * Quaternion.Conjugate(pos.Orientation)); frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation); } diff --git a/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs b/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs index 8386e5a2..873171d9 100644 --- a/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs +++ b/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs @@ -15,10 +15,10 @@ namespace AcDream.Core.Physics.Motion; /// /// = retail m_fOrigin (the accumulated /// position delta, in the mover's LOCAL frame after -/// Position::globaltolocalvec). carries the -/// heading retail's Frame::set_heading writes; read/write it as a -/// compass heading via / -/// (P5 convention, degrees). +/// Position::globaltolocalvec). is the full +/// relative quaternion carried by retail Frame; the heading accessors +/// are narrow helpers for managers that explicitly call +/// Frame::set_heading. /// public sealed class MotionDeltaFrame { @@ -26,10 +26,50 @@ public sealed class MotionDeltaFrame /// delta (mover-local frame). public Vector3 Origin; - /// Retail Frame rotation — carries the - /// Frame::set_heading output. + /// Retail Frame's complete relative rotation. public Quaternion Orientation = Quaternion.Identity; + /// + /// Restore retail's identity Frame: zero translation and identity + /// orientation. Object ticks reuse these accumulators to avoid allocating + /// one transform per entity per render frame. + /// + public void Reset() + { + Origin = Vector3.Zero; + Orientation = Quaternion.Identity; + } + + /// + /// Retail Frame::combine (0x005122E0), specialized for the mutable + /// per-tick delta frame. The incoming translation is expressed in this + /// frame's local coordinates, so the current orientation rotates it before + /// addition; the incoming orientation is then post-multiplied. Translation + /// scaling is the separate m_scale operation performed by + /// CPhysicsObj::UpdatePositionInternal. + /// + public void Combine( + Vector3 localOrigin, + Quaternion localOrientation, + float originScale = 1f) + { + Origin += Vector3.Transform(localOrigin * originScale, Orientation); + Orientation = FrameOps.SetRotate( + Origin, + Orientation, + Orientation * localOrientation); + } + + /// + /// Compose another complete delta frame using retail + /// Frame::combine semantics. + /// + public void Combine(MotionDeltaFrame delta, float originScale = 1f) + { + ArgumentNullException.ThrowIfNull(delta); + Combine(delta.Origin, delta.Orientation, originScale); + } + /// Retail Frame::get_heading (P5 compass degrees). public float GetHeading() => MoveToMath.GetHeading(Orientation); diff --git a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs index 8ffb1990..b9e958df 100644 --- a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs +++ b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs @@ -20,52 +20,25 @@ namespace AcDream.Core.Physics.Motion; /// combine_motion/re_modify — the composition the retired /// AP-73 row approximated with one cycle. /// -/// -/// -/// The / callbacks are the -/// interim ObservedOmega seam: the App seeds the remote body's angular -/// velocity from the wire turn so rotation starts the same tick (retail -/// rotates the body from the sequence omega inside the per-tick -/// apply_physics chain — R6 scope; register row). They fire BEFORE -/// the dispatch so a consumer sees the seed even if the dat lacks the -/// modifier entry. -/// /// public sealed class MotionTableDispatchSink : IInterpretedMotionSink { private readonly AnimationSequencer _sequencer; - /// Turn-class dispatch observed: (motion, signedSpeed) — - /// TurnLeft arrives either as the explicit 0x0E command or as - /// TurnRight + negative speed (adjust_motion wire convention). - public Action? TurnApplied { get; set; } - - /// Turn-class stop observed. - public Action? TurnStopped { get; set; } - public MotionTableDispatchSink(AnimationSequencer sequencer) { ArgumentNullException.ThrowIfNull(sequencer); _sequencer = sequencer; } - private static bool IsTurn(uint motion) - => (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E; - public bool ApplyMotion(uint motion, float speed) { - if (IsTurn(motion)) - TurnApplied?.Invoke(motion, speed); - uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); return result == MotionTableManagerError.Success; } public bool StopMotion(uint motion) { - if (IsTurn(motion)) - TurnStopped?.Invoke(); - uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f)); return result == MotionTableManagerError.Success; } @@ -75,14 +48,10 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink /// (0x00518890): MotionTableManager::PerformMovement(type 5). /// The manager stops everything and queues its Ready-sentinel /// pending_animations entry UNCONDITIONALLY — the completable partner - /// of the interp's A9 pending_motions node. A full stop ends any turn - /// cycle, so the ObservedOmega seam is notified like - /// 's turn-class branch. + /// of the interp's A9 pending_motions node. /// public bool StopCompletely() { - TurnStopped?.Invoke(); - uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely()); return result == MotionTableManagerError.Success; } diff --git a/src/AcDream.Core/Physics/Motion/MoveToMath.cs b/src/AcDream.Core/Physics/Motion/MoveToMath.cs index c170f96c..c31ecc1c 100644 --- a/src/AcDream.Core/Physics/Motion/MoveToMath.cs +++ b/src/AcDream.Core/Physics/Motion/MoveToMath.cs @@ -114,12 +114,8 @@ public static class MoveToMath /// /// Retail Frame::get_heading (0x00535760, raw 319781) — /// extracts the compass heading (P5 convention) from a body orientation - /// quaternion. The packer-reuse trap (V0-pins §P5 correction): - /// acdream's outbound packer (GameWindow.YawToAcQuaternion) is - /// wire-correct at the QUATERNION level but its internal scalar - /// intermediate (headingDeg = 180 - yawDeg) is holtburger's - /// SHIFTED convention, not retail's. This method uses the CORRECT - /// scalar bridge derived from acdream's own body convention + /// quaternion. This method uses the scalar bridge derived from acdream's + /// body convention /// (PlayerMovementController.cs:1022-1025: Orientation = /// AxisAngle(Z, Yaw - PI/2), local-forward = +Y, Yaw=0 faces +X): /// world-forward = (cos Yaw, sin Yaw), so @@ -166,11 +162,10 @@ public static class MoveToMath } /// - /// R4-V5: the scalar leg of for bodies whose - /// authoritative facing is a yaw ANGLE rather than a quaternion (the - /// local player: PlayerMovementController.Yaw, radians, Yaw=0 - /// faces +X, re-synced into the body quaternion every Update). Same P5 - /// bridge: heading = (90 - yawDeg) mod 360. + /// R4-V5: scalar projection of . The local + /// player's Yaw property exposes this horizontal projection while + /// its complete body quaternion remains authoritative. Same P5 bridge: + /// heading = (90 - yawDeg) mod 360. /// public static float HeadingFromYaw(float yawRad) { @@ -181,10 +176,10 @@ public static class MoveToMath } /// - /// R4-V5: exact inverse of — the - /// set_heading seam for yaw-authoritative bodies (the local - /// player's heading snap must write Yaw, NOT the body - /// quaternion, which the controller re-derives from Yaw every frame). + /// R4-V5: exact inverse of — the scalar + /// bridge used by an explicit Frame::set_heading operation. This + /// operation deliberately replaces pitch/roll; ordinary frame composition + /// does not pass through this projection. /// Returns radians wrapped to [-π, π] matching the controller's own /// wrap discipline. /// diff --git a/src/AcDream.Core/Physics/Motion/MovementManager.cs b/src/AcDream.Core/Physics/Motion/MovementManager.cs index 7666dbd0..11c91bbf 100644 --- a/src/AcDream.Core/Physics/Motion/MovementManager.cs +++ b/src/AcDream.Core/Physics/Motion/MovementManager.cs @@ -46,11 +46,11 @@ namespace AcDream.Core.Physics.Motion; /// SetWeenieObject/Destroy have no acdream caller yet — /// get_minterp 0x005242a0 ≡ the property. /// -/// PerformMovement's set_active(1) head -/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active -/// transient bit at spawn (RemoteMotion ctor) and the pre-facade -/// route never re-asserted it — status quo preserved (zero-behavior-change -/// slice), not a new deviation. +/// Activation. Retail begins +/// MovementManager::PerformMovement (0x005240D0, call at 0x005240D9) +/// with an unconditional CPhysicsObj::set_active(1), before validating +/// the movement type. is the host seam for +/// that call. Static objects keep retail's no-op behavior in the host. /// public sealed class MovementManager { @@ -73,6 +73,14 @@ public sealed class MovementManager /// CPhysicsObj. public Func? MoveToFactory { get; set; } + /// + /// Host seam for retail's unconditional + /// CPhysicsObj::set_active(1) at the head of + /// . It is deliberately invoked even for an + /// invalid movement type. + /// + public Action? ActivatePhysicsObject { get; set; } + public MovementManager(MotionInterpreter minterp) { Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp)); @@ -102,6 +110,8 @@ public sealed class MovementManager /// public WeenieError PerformMovement(MovementStruct mvs) { + ActivatePhysicsObject?.Invoke(); + switch (mvs.Type) { case MovementType.RawCommand: diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 9f52557f..51996a3d 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -774,7 +774,7 @@ public sealed class MotionInterpreter : IMotionDoneSink /// is set). Register row: releases a "stuck to object" sticky-manager /// attachment — R5 wires the real StickyManager; until then this is an /// optional callback the App layer may bind, matching the existing - /// Action? seam convention (see MotionTableDispatchSink.TurnStopped). + /// Action? seams on this runtime owner. /// public Action? UnstickFromObject { get; set; } diff --git a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs index cdce28ea..5e73ecd2 100644 --- a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs +++ b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs @@ -59,7 +59,7 @@ public static class PhysicsObjUpdate /// must therefore observe any velocity change made by the movement /// callback; moving that callback after reflection changes the result. /// - public static void CommitSetPositionTransition( + public static bool CommitSetPositionTransition( PhysicsBody body, bool inContact, bool onWalkable, @@ -68,7 +68,9 @@ public static class PhysicsObjUpdate bool previousContact, bool previousOnWalkable, Action? hitGround = null, - Action? leaveGround = null) + Action? leaveGround = null, + Func? isCurrent = null, + Func? isVelocityCurrent = null) { ArgumentNullException.ThrowIfNull(body); @@ -95,11 +97,26 @@ public static class PhysicsObjUpdate body.TransientState &= ~TransientStateFlags.OnWalkable; if (!previousOnWalkable && finalOnWalkable) + { hitGround?.Invoke(); + if (isCurrent?.Invoke() == false) + return false; + } else if (previousOnWalkable && !finalOnWalkable) + { leaveGround?.Invoke(); + if (isCurrent?.Invoke() == false) + return false; + } body.calc_acceleration(); + // Position, Vector, and Movement are independently timestamped but + // can all install m_velocityVector. If a later one arrived from a + // callback above, retain its vector and finish the non-overlapping + // contact/pose commit without applying this older collision response. + if (isVelocityCurrent?.Invoke() == false) + return isCurrent?.Invoke() ?? true; + HandleAllCollisions( body, collisionNormalValid, @@ -107,6 +124,7 @@ public static class PhysicsObjUpdate previousContact, previousOnWalkable, finalOnWalkable); + return isCurrent?.Invoke() ?? true; } /// diff --git a/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs b/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs index a48b645b..6a940353 100644 --- a/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs +++ b/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs @@ -42,6 +42,39 @@ public readonly record struct ProjectileAdvanceResult( Vector3 CollisionNormal, bool TransitionOk); +/// +/// Candidate frame produced by UpdatePhysicsInternal and held across retail's +/// process_hooks slot before the outer transition/collision commit. +/// +public readonly record struct ProjectileQuantumPreparation( + uint CellId, + float Quantum, + bool Simulated, + bool RequiresTransition, + Vector3 BeginPosition, + Quaternion BeginOrientation, + Position BeginCellPosition, + bool BeginInWorld, + ProjectileQuantumDynamics BeginDynamics, + Vector3 CandidatePosition, + Quaternion CandidateOrientation, + ProjectileQuantumDynamics CandidateDynamics, + bool PreviousContact, + bool PreviousOnWalkable); + +/// +/// Mutable kinematic fields produced by the candidate integration. The split +/// App scheduler holds these transactionally across process_hooks so a +/// re-entrant authoritative correction never inherits an abandoned impulse. +/// +public readonly record struct ProjectileQuantumDynamics( + Vector3 Velocity, + Vector3 CachedVelocity, + Vector3 Acceleration, + Vector3 Omega, + TransientStateFlags TransientState, + double LastUpdateTime); + /// /// Core-only port of the retail live-projectile physics driver. It owns no /// renderer, network, or world-lifetime state: the App controller supplies a @@ -152,32 +185,84 @@ public sealed class ProjectilePhysicsStepper transitionOk); } - private void StepQuantum( + /// + /// Advances exactly one quantum already admitted by the owning + /// . This is the live-object path: + /// one CPhysicsObj clock admits PartArray, projectile physics, hooks, and + /// the manager tail together. The absolute-time overload remains for the + /// pure stepper conformance surface. + /// + public ProjectileAdvanceResult AdvanceQuantum( PhysicsBody body, - float dt, - ref uint cellId, + float quantum, + uint cellId, ProjectileCollisionSphere sphere, uint movingEntityId, - uint designatedTargetId, - ref bool collisionNormalValid, - ref Vector3 collisionNormal, - ref bool transitionOk) + uint designatedTargetId = 0, + bool isParented = false) { + ProjectileQuantumPreparation preparation = BeginQuantum( + body, + quantum, + cellId, + sphere, + isParented); + return CompleteQuantum( + body, + preparation, + sphere, + movingEntityId, + designatedTargetId); + } + + public ProjectileQuantumPreparation BeginQuantum( + PhysicsBody body, + float quantum, + uint cellId, + ProjectileCollisionSphere sphere, + bool isParented = false) + { + ArgumentNullException.ThrowIfNull(body); + if (!float.IsFinite(quantum) + || quantum <= PhysicsGlobals.EPSILON + || quantum > PhysicsBody.MaxQuantum) + { + throw new ArgumentOutOfRangeException( + nameof(quantum), + quantum, + "An admitted object quantum must be finite and no larger than MaxQuantum."); + } + + if (!sphere.IsValid) + return NotSimulated(cellId, quantum); + + if (isParented || !body.InWorld + || body.State.HasFlag(PhysicsStateFlags.Frozen) + || body.State.HasFlag(PhysicsStateFlags.Static) + || cellId == 0) + { + body.TransientState &= ~TransientStateFlags.Active; + return NotSimulated(cellId, quantum); + } + + if (!body.IsActive) + return NotSimulated(cellId, quantum); + Vector3 beginPosition = body.Position; Quaternion beginOrientation = body.Orientation; + Position beginCellPosition = body.CellPosition; + bool beginInWorld = body.InWorld; + ProjectileQuantumDynamics beginDynamics = CaptureDynamics(body); bool previousContact = body.InContact; bool previousOnWalkable = body.OnWalkable; - // Final PhysicsState drives acceleration on every quantum. Network - // acceleration is retained by the protocol parser but not installed. body.calc_acceleration(); - body.UpdatePhysicsInternal(dt); + body.UpdatePhysicsInternal(quantum); Vector3 candidatePosition = body.Position; Quaternion candidateOrientation = body.Orientation; Vector3 displacement = candidatePosition - beginPosition; bool candidateMoved = displacement != Vector3.Zero; - if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath)) { candidateOrientation = RetailFrameMath.SetVectorHeading( @@ -189,76 +274,218 @@ public sealed class ProjectilePhysicsStepper { body.CachedVelocity = Vector3.Zero; body.Orientation = candidateOrientation; - return; + body.LastUpdateTime += quantum; + } + else + { + // process_hooks runs against the post-physics candidate while the + // authoritative root remains at the begin frame until transition. + body.Position = beginPosition; + body.Orientation = beginOrientation; } - // The integrator produces a candidate without committing the root. - // Restore the authoritative start frame before transition setup so - // carried cell-relative position and self-shadow identity remain exact. - body.Position = beginPosition; - body.Orientation = beginOrientation; + ProjectileQuantumDynamics candidateDynamics = CaptureDynamics(body); + RestoreBeginFrame( + body, + beginPosition, + beginOrientation, + beginCellPosition, + beginInWorld); + ApplyDynamics(body, beginDynamics); + return new ProjectileQuantumPreparation( + cellId, + quantum, + Simulated: true, + RequiresTransition: candidateMoved, + beginPosition, + beginOrientation, + beginCellPosition, + beginInWorld, + beginDynamics, + candidatePosition, + candidateOrientation, + candidateDynamics, + previousContact, + previousOnWalkable); + } + + public ProjectileAdvanceResult CompleteQuantum( + PhysicsBody body, + in ProjectileQuantumPreparation preparation, + ProjectileCollisionSphere sphere, + uint movingEntityId, + uint designatedTargetId = 0) + { + ArgumentNullException.ThrowIfNull(body); + if (!preparation.Simulated) + return new ProjectileAdvanceResult(preparation.CellId, 0, false, false, default, true); + ApplyDynamics(body, preparation.CandidateDynamics); + if (!preparation.RequiresTransition) + { + body.SetFrameInCurrentCell( + preparation.CandidatePosition, + preparation.CandidateOrientation); + return new ProjectileAdvanceResult(preparation.CellId, 1, true, false, default, true); + } + + uint cellId = preparation.CellId; ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped) ? ObjectInfoState.PathClipped : ObjectInfoState.None; var resolved = _physics.ResolveWithTransition( - beginPosition, - candidatePosition, + preparation.BeginPosition, + preparation.CandidatePosition, cellId, sphereRadius: sphere.Radius, sphereHeight: 0f, stepUpHeight: PhysicsGlobals.DefaultStepHeight, stepDownHeight: 0f, - isOnGround: previousOnWalkable, + isOnGround: preparation.PreviousOnWalkable, body: body, moverFlags: moverFlags, movingEntityId: movingEntityId, localSphereOrigin: sphere.LocalOrigin, - beginOrientation: beginOrientation, - endOrientation: candidateOrientation, + beginOrientation: preparation.BeginOrientation, + endOrientation: preparation.CandidateOrientation, designatedTargetId: designatedTargetId); + bool collisionNormalValid = false; + Vector3 collisionNormal = default; + bool transitionOk = resolved.Ok; if (!resolved.Ok) { - // UpdateObjectInternal 0x005156B0: when transition() returns null, - // retail calls set_frame(candidate), zeros cached_velocity, and - // deliberately skips SetPositionInternal/handle_all_collisions. - // set_frame retains Position.objcell_id even when the candidate - // frame lies beyond the current outdoor cell's canonical range. - body.SetFrameInCurrentCell(candidatePosition, candidateOrientation); + body.SetFrameInCurrentCell( + preparation.CandidatePosition, + preparation.CandidateOrientation); body.CachedVelocity = Vector3.Zero; - transitionOk = false; - return; } - - body.CachedVelocity = (resolved.Position - beginPosition) / dt; - body.Position = resolved.Position; - body.Orientation = resolved.Orientation == default - ? candidateOrientation - : resolved.Orientation; - if (resolved.CellId != 0) - cellId = resolved.CellId; - - // SetPositionInternal 0x00515330 commits Contact and OnWalkable as - // distinct facts before handle_all_collisions. A steep surface can be - // contact without becoming walkable ground. - PhysicsObjUpdate.ApplySetPositionContact( - body, - resolved.InContact, - resolved.OnWalkable); - - PhysicsObjUpdate.HandleAllCollisions( - body, - resolved.CollisionNormalValid, - resolved.CollisionNormal, - previousContact, - previousOnWalkable, - nowOnWalkable: body.OnWalkable); - - if (resolved.CollisionNormalValid) + else { - collisionNormalValid = true; - collisionNormal = resolved.CollisionNormal; + body.CachedVelocity = (resolved.Position - preparation.BeginPosition) + / preparation.Quantum; + body.Position = resolved.Position; + body.Orientation = resolved.Orientation == default + ? preparation.CandidateOrientation + : resolved.Orientation; + if (resolved.CellId != 0) + cellId = resolved.CellId; + + PhysicsObjUpdate.ApplySetPositionContact( + body, + resolved.InContact, + resolved.OnWalkable); + PhysicsObjUpdate.HandleAllCollisions( + body, + resolved.CollisionNormalValid, + resolved.CollisionNormal, + preparation.PreviousContact, + preparation.PreviousOnWalkable, + nowOnWalkable: body.OnWalkable); + if (resolved.CollisionNormalValid) + { + collisionNormalValid = true; + collisionNormal = resolved.CollisionNormal; + } } + + body.LastUpdateTime += preparation.Quantum; + return new ProjectileAdvanceResult( + cellId, + 1, + true, + collisionNormalValid, + collisionNormal, + transitionOk); + } + + private static ProjectileQuantumPreparation NotSimulated( + uint cellId, + float quantum) => new( + CellId: cellId, + Quantum: quantum, + Simulated: false, + RequiresTransition: false, + BeginPosition: default, + BeginOrientation: default, + BeginCellPosition: default, + BeginInWorld: false, + BeginDynamics: default, + CandidatePosition: default, + CandidateOrientation: default, + CandidateDynamics: default, + PreviousContact: false, + PreviousOnWalkable: false); + + private static ProjectileQuantumDynamics CaptureDynamics(PhysicsBody body) => new( + body.Velocity, + body.CachedVelocity, + body.Acceleration, + body.Omega, + body.TransientState, + body.LastUpdateTime); + + private static void ApplyDynamics( + PhysicsBody body, + in ProjectileQuantumDynamics dynamics) + { + body.Velocity = dynamics.Velocity; + body.CachedVelocity = dynamics.CachedVelocity; + body.Acceleration = dynamics.Acceleration; + body.Omega = dynamics.Omega; + body.TransientState = dynamics.TransientState; + body.LastUpdateTime = dynamics.LastUpdateTime; + } + + private static void RestoreBeginFrame( + PhysicsBody body, + Vector3 beginPosition, + Quaternion beginOrientation, + in Position beginCellPosition, + bool beginInWorld) + { + body.Orientation = beginOrientation; + if (beginCellPosition.ObjCellId != 0) + { + body.SnapToCell( + beginCellPosition.ObjCellId, + beginPosition, + beginCellPosition.Frame.Origin); + } + else + { + body.SetFrameInCurrentCell(beginPosition, beginOrientation); + } + body.InWorld = beginInWorld; + } + + private void StepQuantum( + PhysicsBody body, + float dt, + ref uint cellId, + ProjectileCollisionSphere sphere, + uint movingEntityId, + uint designatedTargetId, + ref bool collisionNormalValid, + ref Vector3 collisionNormal, + ref bool transitionOk) + { + ProjectileQuantumPreparation preparation = BeginQuantum( + body, + dt, + cellId, + sphere); + ProjectileAdvanceResult result = CompleteQuantum( + body, + preparation, + sphere, + movingEntityId, + designatedTargetId); + if (result.CellId != 0) + cellId = result.CellId; + collisionNormalValid |= result.CollisionNormalValid; + if (result.CollisionNormalValid) + collisionNormal = result.CollisionNormal; + transitionOk &= result.TransitionOk; } } diff --git a/src/AcDream.Core/Physics/RemoteMotionCombiner.cs b/src/AcDream.Core/Physics/RemoteMotionCombiner.cs index 0ebb65d0..28a4bc70 100644 --- a/src/AcDream.Core/Physics/RemoteMotionCombiner.cs +++ b/src/AcDream.Core/Physics/RemoteMotionCombiner.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.Core.Physics.Motion; namespace AcDream.Core.Physics; @@ -7,10 +8,10 @@ namespace AcDream.Core.Physics; /// by CSequence::update + InterpolationManager catch-up correction. /// Pure function — no side effects or hidden state. /// -/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730): -/// rootOffset = CPartArray::Update(dt) // animation -/// PositionManager::adjust_offset(rootOffset) // adds correction -/// frame.origin += rootOffset +/// Mirrors retail CPhysicsObj::UpdatePositionInternal (0x00512C30): +/// CPartArray writes one complete local Frame, then PositionManager mutates +/// that same Frame. Active interpolation replaces it with +/// Position::subtract2; later managers receive the result. /// /// The animation root motion is the complete body-local Frame.Origin /// accumulated by CPartArray::Update: authored PosFrames plus the @@ -30,6 +31,50 @@ namespace AcDream.Core.Physics; /// public sealed class RemoteMotionCombiner { + /// + /// Compose retail's complete per-object delta frame. Interpolation, when + /// active, replaces the PartArray frame via + /// Position::subtract2; otherwise the authored root frame remains. + /// + /// true when interpolation replaced the root frame. + public bool ComposeOffset( + double dt, + Vector3 currentBodyPosition, + Quaternion ori, + MotionDeltaFrame rootMotionLocalFrame, + InterpolationManager interp, + float maxSpeed, + MotionDeltaFrame output, + Vector3? terrainNormal = null, + bool inContact = true) + { + ArgumentNullException.ThrowIfNull(rootMotionLocalFrame); + ArgumentNullException.ThrowIfNull(interp); + ArgumentNullException.ThrowIfNull(output); + + output.Origin = rootMotionLocalFrame.Origin; + output.Orientation = rootMotionLocalFrame.Orientation; + bool interpolationOverwrote = interp.AdjustOffset( + dt, + currentBodyPosition, + ori, + maxSpeed, + output, + inContact); + + if (!interpolationOverwrote + && terrainNormal.HasValue + && terrainNormal.Value.Z > 0.01f) + { + Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori); + Vector3 normal = terrainNormal.Value; + rootMotionWorld -= normal * Vector3.Dot(rootMotionWorld, normal); + output.Origin = MoveToMath.GlobalToLocalVec(ori, rootMotionWorld); + } + + return interpolationOverwrote; + } + /// /// Compute the per-frame world-space delta to add to body.Position. /// @@ -90,11 +135,21 @@ public sealed class RemoteMotionCombiner // AdjustOffset returns Vector3.Zero in two cases mapped to retail's // early-return: empty queue OR distance < DesiredDistance (0.05m). // In both, body falls back to animation root motion. - Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed); - if (correction.LengthSquared() > 0f) - return correction; - - Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocalDelta, ori); + var root = new MotionDeltaFrame + { + Origin = rootMotionLocalDelta, + }; + var output = new MotionDeltaFrame(); + ComposeOffset( + dt, + currentBodyPosition, + ori, + root, + interp, + maxSpeed, + output, + terrainNormal: null); + Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori); // Slope projection (queue-empty fallback only). Locomotion cycles // bake Z=0 in body-local, so without projection the body's Z stays diff --git a/src/AcDream.Core/Physics/RetailObjectActivityGate.cs b/src/AcDream.Core/Physics/RetailObjectActivityGate.cs new file mode 100644 index 00000000..4a8d372b --- /dev/null +++ b/src/AcDream.Core/Physics/RetailObjectActivityGate.cs @@ -0,0 +1,101 @@ +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Ports the lifecycle and 96-world-unit Active gate at the head of retail +/// CPhysicsObj::update_object (0x00515D10) plus the inactive branch of +/// UpdateObjectInternal (0x005156B0). +/// +public static class RetailObjectActivityGate +{ + public const float MaxPhysicsDistance = 96f; + + public static RetailObjectActivityResult Evaluate( + RetailObjectQuantumClock clock, + PhysicsBody? body, + bool lifecycleEligible, + bool hasPartArray, + bool isStatic, + Vector3 objectPosition, + Vector3? playerPosition, + double elapsedSeconds) + { + ArgumentNullException.ThrowIfNull(clock); + + if (!lifecycleEligible) + { + SetActive(clock, body, active: false); + return RetailObjectActivityResult.Suspended; + } + + // Retail performs both the 96-unit decision and set_active(1) only + // while player_object exists. During login/session transitions a + // missing player preserves the prior Active state; it must not wake a + // previously distant object. + if (playerPosition is null) + { + if (clock.IsActive) + return RetailObjectActivityResult.Active; + clock.Advance(elapsedSeconds); + return RetailObjectActivityResult.Inactive; + } + + bool withinActiveBubble = !hasPartArray + || Vector3.Distance(objectPosition, playerPosition.Value) + <= MaxPhysicsDistance; + if (!withinActiveBubble) + { + SetActive(clock, body, active: false); + // Inactive ordinary objects still consume update_time and run + // their particle/script tail. Those owners tick elsewhere in + // acdream; consuming the batch here prevents later catch-up. + clock.Advance(elapsedSeconds); + return RetailObjectActivityResult.Inactive; + } + + // CPhysicsObj::set_active(1) (0x0050FC40) is a no-op for Static. + // Static objects that were initialized or removed from visibility + // inactive therefore stay on UpdateObjectInternal's particle/script + // tail instead of entering root physics. + bool reactivated = false; + if (!isStatic) + { + reactivated = clock.Activate(); + if (body is not null) + body.TransientState |= TransientStateFlags.Active; + } + + if (!clock.IsActive) + { + clock.Advance(elapsedSeconds); + return RetailObjectActivityResult.Inactive; + } + + return reactivated + ? RetailObjectActivityResult.Reactivated + : RetailObjectActivityResult.Active; + } + + private static void SetActive( + RetailObjectQuantumClock clock, + PhysicsBody? body, + bool active) + { + if (active) + clock.Activate(); + else + clock.Deactivate(); + + if (body is not null && !active) + body.TransientState &= ~TransientStateFlags.Active; + } +} + +public enum RetailObjectActivityResult +{ + Suspended, + Inactive, + Reactivated, + Active, +} diff --git a/src/AcDream.Core/Physics/RetailObjectManagerTail.cs b/src/AcDream.Core/Physics/RetailObjectManagerTail.cs index 17fac1cd..3340aa42 100644 --- a/src/AcDream.Core/Physics/RetailObjectManagerTail.cs +++ b/src/AcDream.Core/Physics/RetailObjectManagerTail.cs @@ -12,6 +12,40 @@ namespace AcDream.Core.Physics; /// public static class RetailObjectManagerTail { + /// + /// Allocation-free production overload for the concrete retail manager + /// owners. DetectionManager is not ported, so its ordered slot is empty. + /// + public static void Run( + Motion.TargetManager? target, + Motion.MovementManager? movement, + Motion.MotionTableManager? partArray, + Motion.PositionManager? position) + { + target?.HandleTargetting(); + movement?.UseTime(); + partArray?.UseTime(); + position?.UseTime(); + } + + /// + /// Allocation-free local-player overload. Its target action and PartArray + /// completion action are cached ownership seams; the other managers are + /// passed as concrete objects rather than allocating bound delegates per + /// quantum. + /// + public static void Run( + Action? handleTargeting, + Motion.MovementManager? movement, + Action? partArrayHandleMovement, + Motion.PositionManager? position) + { + handleTargeting?.Invoke(); + movement?.UseTime(); + partArrayHandleMovement?.Invoke(); + position?.UseTime(); + } + public static void Run( Action? checkDetection, Action? handleTargeting, diff --git a/src/AcDream.Core/Physics/RetailObjectQuantumClock.cs b/src/AcDream.Core/Physics/RetailObjectQuantumClock.cs new file mode 100644 index 00000000..7b2ddd2d --- /dev/null +++ b/src/AcDream.Core/Physics/RetailObjectQuantumClock.cs @@ -0,0 +1,122 @@ +using System; + +namespace AcDream.Core.Physics; + +/// +/// Allocation-free port of CPhysicsObj::update_object +/// (0x00515D10). One clock belongs to one logical physics object. It retains +/// sub-minimum elapsed time, subdivides long frames into complete object +/// updates, and discards stale gaps greater than retail's huge quantum. +/// +public sealed class RetailObjectQuantumClock +{ + private double _pending; + + public double PendingSeconds => _pending; + public bool IsActive { get; private set; } = true; + + public RetailObjectQuantumBatch Advance(double elapsedSeconds) + { + if (double.IsNaN(elapsedSeconds) || elapsedSeconds < 0.0) + { + _pending = 0.0; + return new RetailObjectQuantumBatch(0, 0f, Discarded: true); + } + + double elapsed = _pending + elapsedSeconds; + if (elapsed <= FrameEpsilon) + { + _pending = 0.0; + return default; + } + + if (elapsed > PhysicsBody.HugeQuantum) + { + _pending = 0.0; + return new RetailObjectQuantumBatch(0, 0f, Discarded: true); + } + + int fullSteps = 0; + while (elapsed > PhysicsBody.MaxQuantum) + { + fullSteps++; + elapsed -= PhysicsBody.MaxQuantum; + } + + float remainder = 0f; + if (elapsed > PhysicsBody.MinQuantum) + { + remainder = (float)elapsed; + elapsed = 0.0; + } + + _pending = elapsed; + return new RetailObjectQuantumBatch(fullSteps, remainder, Discarded: false); + } + + /// + /// Retail set_active(0): suppress the full object path without + /// advancing or rebasing update_time. + /// + public void Deactivate() => IsActive = false; + + /// + /// Retail set_active(1). An inactive-to-active edge rebases + /// update_time to the current timer, so the reactivation frame does + /// not catch up suppressed time. + /// + /// True only when this call performed the reactivation edge. + public bool Activate() + { + if (IsActive) + return false; + IsActive = true; + _pending = 0.0; + return true; + } + + public void Reset() => _pending = 0.0; + + /// + /// Retail CPhysicsObj::prepare_to_enter_world (0x00511FA0): rebase + /// update_time to the current timer and immediately set Active when + /// the object is not Static. A Static object retains its prior Active bit; + /// normal initial entry and re-entry arrive here inactive. The next elapsed + /// frame of a non-Static object is therefore eligible for ordinary object- + /// quantum admission; there is no second activation frame to discard. + /// + public void ResetForEnterWorld(bool isStatic = false) + { + _pending = 0.0; + if (!isStatic) + IsActive = true; + } + + private const double FrameEpsilon = 0.000199999995; +} + +/// +/// How the owning live-object lifecycle treats this render frame before +/// entering retail CPhysicsObj::update_object. +/// +public enum RetailObjectClockDisposition +{ + Advance, + Suspend, +} + +/// Compact result of one retail object-clock admission pass. +public readonly record struct RetailObjectQuantumBatch( + int FullSteps, + float Remainder, + bool Discarded) +{ + public int Count => FullSteps + (Remainder > 0f ? 1 : 0); + + public float GetQuantum(int index) + { + if ((uint)index >= (uint)Count) + throw new ArgumentOutOfRangeException(nameof(index)); + return index < FullSteps ? PhysicsBody.MaxQuantum : Remainder; + } +} diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs new file mode 100644 index 00000000..20575243 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs @@ -0,0 +1,123 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Input; + +public sealed class LocalPlayerProjectionControllerTests +{ + [Fact] + public void Project_PreservesCompleteAuthoritativeBodyQuaternion() + { + const uint cellId = 0x01010001u; + Quaternion complete = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f) + * Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f)); + var movement = new PlayerMovementController(new PhysicsEngine()); + movement.SetPosition(Vector3.Zero, cellId, Vector3.Zero); + movement.SetBodyOrientation(complete); + var entity = new WorldEntity + { + Id = 7u, + ServerGuid = 0x50000001u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + var projection = new LocalPlayerProjectionController( + () => entity, + () => 1, + () => 1, + (_, _) => { }, + (_, _) => { }, + _ => true, + _ => { }); + + projection.Project( + movement, + movement.CapturePresentationResult(), + hidden: false); + + Assert.InRange( + MathF.Abs(Quaternion.Dot( + complete, + Quaternion.Normalize(entity.Rotation))), + 0.99999f, + 1.00001f); + } + + [Fact] + public void Project_AlreadyPendingChangedPose_DoesNotResurrectShadow() + { + const uint cellId = 0x02020001u; + var movement = new PlayerMovementController(new PhysicsEngine()); + movement.SetPosition(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero); + var entity = new WorldEntity + { + Id = 8u, + ServerGuid = 0x50000001u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + var order = new List(); + var projection = new LocalPlayerProjectionController( + () => entity, + () => 2, + () => 2, + (_, _) => order.Add("shadow"), + (_, _) => order.Add("rebucket"), + _ => false, + _ => order.Add("suspend")); + + projection.Project( + movement, + movement.CapturePresentationResult(), + hidden: false); + + Assert.Equal(["rebucket", "suspend"], order); + Assert.Equal(movement.Position, entity.Position); + Assert.Equal(cellId, entity.ParentCellId); + } + + [Fact] + public void Project_PendingToLoaded_RebucketsBeforeStationaryShadowRestore() + { + const uint cellId = 0x02020001u; + var movement = new PlayerMovementController(new PhysicsEngine()); + movement.SetPosition(new Vector3(12f, 8f, 3f), cellId, Vector3.Zero); + var entity = new WorldEntity + { + Id = 9u, + ServerGuid = 0x50000001u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = movement.Position, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + bool visible = false; + var order = new List(); + var projection = new LocalPlayerProjectionController( + () => entity, + () => 2, + () => 2, + (_, _) => order.Add("shadow"), + (_, _) => + { + order.Add("rebucket"); + visible = true; + }, + _ => visible, + _ => order.Add("suspend")); + + projection.Project( + movement, + movement.CapturePresentationResult(), + hidden: false); + + Assert.Equal(["rebucket", "shadow"], order); + } +} diff --git a/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs b/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs index eea533eb..a93c51ec 100644 --- a/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs +++ b/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs @@ -38,7 +38,12 @@ public sealed class PlayerMouseLookMovementTests float yawBefore = controller.Yaw; controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput()); - MovementResult turning = controller.Update(1f / 60f, new MovementInput()); + // Rotation is authored/applied on an admitted retail object quantum, + // not directly by the mouse sample. Stay below the outbound mouse + // cadence while crossing the strict physics minimum. + MovementResult turning = controller.Update( + PhysicsBody.MinQuantum + 0.001f, + new MovementInput()); Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand); Assert.Equal(0.8f, turning.TurnSpeed); diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs index 1048d673..00964469 100644 --- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs @@ -50,7 +50,10 @@ public sealed class RetailLocalPlayerFrameControllerTests }, () => calls.Add("spatial")); - live.Tick(1f / 60f); + // CPhysicsObj::update_object admits manager time only after the strict + // minimum quantum. Use one admitted object tick; the test's concern is + // the network barrier, not render-fragment accumulation. + live.Tick(PhysicsBody.MaxQuantum); Assert.True(local.TryGetPresentationAfterNetwork(out var presentation)); Assert.Equal( @@ -150,6 +153,119 @@ public sealed class RetailLocalPlayerFrameControllerTests Assert.Equal(controller.Position, positionSeenByTargetManager); } + [Fact] + public void FrozenObject_IsPresentedWithoutPhysicsInputOrOutboundState() + { + PlayerMovementController controller = CreateController(); + Vector3 initial = controller.Position; + int inputCaptures = 0; + int projections = 0; + int preNetwork = 0; + int postNetwork = 0; + var local = new RetailLocalPlayerFrameController( + canPresentPlayer: () => true, + getController: () => controller, + captureInput: () => + { + inputCaptures++; + return new MovementInput(Forward: true); + }, + resolveLocalEntityId: () => 7u, + handleTargeting: () => throw new InvalidOperationException( + "Frozen object advanced its manager tail."), + isHidden: () => false, + project: (_, _, _) => projections++, + sendPreNetwork: (_, _, _) => preNetwork++, + sendPostNetwork: (_, _) => postNetwork++, + objectClockDisposition: () => + RetailObjectClockDisposition.Suspend); + + local.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum); + local.RunPostNetworkCommandPhase(); + Assert.True(local.TryGetPresentationAfterNetwork(out var presentation)); + + Assert.Equal(initial, controller.Position); + Assert.Equal(PhysicsBody.MaxQuantum, controller.SimTimeSeconds); + Assert.Equal(0, inputCaptures); + Assert.Equal(2, projections); + Assert.Equal(0, preNetwork); + Assert.Equal(0, postNetwork); + Assert.False(presentation.AdvancedBeforeNetwork); + } + + [Fact] + public void HiddenPoseIsDirtyOnlyWhenACompleteObjectQuantumRuns() + { + PlayerMovementController controller = CreateController(); + var local = new RetailLocalPlayerFrameController( + canPresentPlayer: () => true, + getController: () => controller, + captureInput: () => new MovementInput(), + resolveLocalEntityId: () => 7u, + handleTargeting: () => { }, + isHidden: () => true, + project: (_, _, _) => { }, + sendPreNetwork: (_, _, _) => { }, + sendPostNetwork: (_, _) => { }); + + local.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum); + Assert.True(local.HiddenPartPoseDirty); + + local.AdvanceBeforeNetwork(PhysicsBody.MinQuantum / 2f); + Assert.False(local.HiddenPartPoseDirty); + } + + [Fact] + public void InvalidElapsed_PublishesSnapshotWithoutInputOrNetworkCallbacks() + { + PlayerMovementController controller = CreateController(); + int inputCaptures = 0; + int targeting = 0; + int projections = 0; + int preNetwork = 0; + int postNetwork = 0; + var local = new RetailLocalPlayerFrameController( + canPresentPlayer: () => true, + getController: () => controller, + captureInput: () => + { + inputCaptures++; + return new MovementInput(Forward: true); + }, + resolveLocalEntityId: () => 7u, + handleTargeting: () => targeting++, + isHidden: () => false, + project: (_, _, _) => projections++, + sendPreNetwork: (_, _, _) => preNetwork++, + sendPostNetwork: (_, _) => postNetwork++); + float initialTime = controller.SimTimeSeconds; + Vector3 initialPosition = controller.Position; + + foreach (float elapsed in new[] + { + float.NaN, + float.PositiveInfinity, + float.NegativeInfinity, + -1f, + 0f, + }) + { + local.AdvanceBeforeNetwork(elapsed); + local.RunPostNetworkCommandPhase(); + Assert.True(local.TryGetPresentationAfterNetwork(out var frame)); + Assert.False(frame.AdvancedBeforeNetwork); + Assert.False(local.HiddenPartPoseDirty); + } + + Assert.Equal(initialTime, controller.SimTimeSeconds); + Assert.Equal(initialPosition, controller.Position); + Assert.Equal(0, inputCaptures); + Assert.Equal(0, targeting); + Assert.Equal(0, preNetwork); + Assert.Equal(0, postNetwork); + Assert.Equal(10, projections); + } + private static PlayerMovementController CreateController() { var engine = new PhysicsEngine(); @@ -167,8 +283,10 @@ public sealed class RetailLocalPlayerFrameControllerTests worldOffsetX: 0f, worldOffsetY: 0f); - var controller = new PlayerMovementController(engine); + var clock = new RetailObjectQuantumClock(); + var controller = new PlayerMovementController(engine, clock); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u); + Assert.True(clock.IsActive); return controller; } } diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs new file mode 100644 index 00000000..5d50faa0 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs @@ -0,0 +1,246 @@ +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; +using AcDream.Core.World; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Physics; + +public sealed class LiveEntityOrdinaryPhysicsUpdaterTests +{ + private const uint Guid = 0x70000071u; + private const uint SourceCell = 0xA9B40039u; + private const uint DestinationCell = 0xAAB40001u; + + [Fact] + public void CompleteRootFrame_CrossesTransitionAndCommitsCanonicalCell() + { + PhysicsEngine physics = BuildBoundaryEngine(); + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); + var live = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + LiveEntityRecord record = live.RegisterLiveEntity(Spawn()).Record!; + WorldEntity entity = live.MaterializeLiveEntity( + Guid, + SourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(191f, 10f, 50f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = SourceCell, + })!; + + var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion + { + CellId = SourceCell, + }; + remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable; + remote.Body.SnapToCell( + SourceCell, + entity.Position, + entity.Position); + live.SetRemoteMotionRuntime(Guid, remote); + Assert.True(live.ClearRemoteMotionRuntime(Guid)); + ulong epoch = record.ObjectClockEpoch; + + var updater = new LiveEntityOrdinaryPhysicsUpdater( + physics, + (_, _) => (0.48f, 1.835f)); + var rootFrame = new Frame + { + Origin = new Vector3(2f, 0f, 0f), + Orientation = Quaternion.Identity, + }; + + Assert.True(updater.Tick( + live, + record, + entity, + rootFrame, + objectScale: 1f, + quantum: 0.1f, + liveCenterX: 0xA9, + liveCenterY: 0xB4, + objectClockEpoch: epoch, + sequencer: null, + captureAnimationHooks: (_, _) => { })); + + Assert.Equal(DestinationCell, record.FullCellId); + Assert.Equal(DestinationCell, entity.ParentCellId); + Assert.Equal(DestinationCell, record.PhysicsBody!.CellPosition.ObjCellId); + Assert.True(entity.Position.X > 192f); + Assert.Equal(entity.Position, record.PhysicsBody.Position); + Assert.Equal(epoch, record.ObjectClockEpoch); + } + + [Fact] + public void HookDeletion_PreventsTransitionAndStaleEntityCommit() + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + var live = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + LiveEntityRecord record = live.RegisterLiveEntity(Spawn()).Record!; + WorldEntity entity = live.MaterializeLiveEntity( + Guid, + SourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(191f, 10f, 50f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = SourceCell, + })!; + var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion + { + CellId = SourceCell, + }; + remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable; + remote.Body.SnapToCell(SourceCell, entity.Position, entity.Position); + live.SetRemoteMotionRuntime(Guid, remote); + live.ClearRemoteMotionRuntime(Guid); + var setup = new DatReaderWriter.DBObjs.Setup(); + var sequencer = new AnimationSequencer( + setup, + new DatReaderWriter.DBObjs.MotionTable(), + new NullAnimationLoader()); + Vector3 retainedEntityPosition = entity.Position; + var updater = new LiveEntityOrdinaryPhysicsUpdater( + new PhysicsEngine(), + (_, _) => (0.48f, 1.835f)); + + Assert.False(updater.Tick( + live, + record, + entity, + new Frame + { + Origin = Vector3.UnitX, + Orientation = Quaternion.Identity, + }, + 1f, + 0.1f, + 0xA9, + 0xB4, + record.ObjectClockEpoch, + sequencer, + (_, _) => live.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, record.Generation), + isLocalPlayer: false))); + + Assert.False(live.TryGetRecord(Guid, out _)); + Assert.Equal(retainedEntityPosition, entity.Position); + } + + private static PhysicsEngine BuildBoundaryEngine() + { + static TerrainSurface FlatTerrain() + { + var heights = new byte[81]; + var table = new float[256]; + Array.Fill(table, 50f); + return new TerrainSurface(heights, table); + } + + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + 0xA9B4FFFFu, + FlatTerrain(), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + engine.AddLandblock( + 0xAAB4FFFFu, + FlatTerrain(), + Array.Empty(), + Array.Empty(), + 192f, + 0f); + return engine; + } + + private static LoadedLandblock EmptyLandblock(uint landblockId) => + new( + landblockId, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty()); + + private static WorldSession.EntitySpawn Spawn() + { + var position = new CreateObject.ServerPosition( + SourceCell, + 191f, + 10f, + 50f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + var physics = new PhysicsSpawnData( + RawState: 0, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + 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, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "ordinary physics fixture", + null, + null, + null, + PhysicsState: 0, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class NullAnimationLoader : IAnimationLoader + { + public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs b/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs index b33873b3..6c10f77c 100644 --- a/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs +++ b/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs @@ -23,13 +23,12 @@ public sealed class PlayerOutboundPositionTests 0xA9B20021u, new Vector3(116.07f, 22.56f, 83.76f)); - Assert.True(controller.TryGetOutboundPosition( - Quaternion.Identity, - out uint cellId, - out Vector3 localOrigin)); - Assert.Equal(0xA9B20021u, cellId); - Assert.Equal(new Vector3(116.07f, 22.56f, 83.76f), localOrigin); - Assert.NotEqual(controller.Position, localOrigin); + Assert.True(controller.TryGetOutboundPosition(out Position outbound)); + Assert.Equal(0xA9B20021u, outbound.ObjCellId); + Assert.Equal( + new Vector3(116.07f, 22.56f, 83.76f), + outbound.Frame.Origin); + Assert.NotEqual(controller.Position, outbound.Frame.Origin); } [Fact] @@ -37,10 +36,30 @@ public sealed class PlayerOutboundPositionTests { var controller = new PlayerMovementController(new PhysicsEngine()); - Assert.False(controller.TryGetOutboundPosition( - Quaternion.Identity, - out _, - out _)); + Assert.False(controller.TryGetOutboundPosition(out _)); + } + + [Fact] + public void OutboundPosition_PreservesCompleteAuthoritativeQuaternion() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + controller.SetPosition( + new Vector3(116.07f, 214.56f, 83.76f), + 0xA9B20021u, + new Vector3(116.07f, 22.56f, 83.76f)); + Quaternion complete = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f) + * Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f)); + controller.SetBodyOrientation(complete); + + Assert.True(controller.TryGetOutboundPosition(out Position outbound)); + + Assert.InRange( + MathF.Abs(Quaternion.Dot( + complete, + Quaternion.Normalize(outbound.Frame.Orientation))), + 0.99999f, + 1.00001f); } [Fact] diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index abd03fb0..aefa2284 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -77,19 +77,19 @@ public sealed class ProjectileControllerTests | PhysicsStateFlags.Hidden | PhysicsStateFlags.IgnoreCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 1.1, 1, 1)); + record, record.FinalPhysicsState, 1.1, 1, 1)); Vector3 hiddenPosition = entity.Position; fixture.Controller.Tick(5.0, 1, 1, playerWorldPosition: null); Assert.Equal(hiddenPosition, entity.Position); Assert.True(body.InWorld); Assert.True(body.IsActive); - Assert.Equal(5.0, body.LastUpdateTime, 8); + Assert.Equal(0.0, record.ObjectClock.PendingSeconds, 8); Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 5.0, 1, 1)); + record, record.FinalPhysicsState, 5.0, 1, 1)); fixture.Controller.Tick(5.1, 1, 1, playerWorldPosition: null); Assert.True(entity.Position.X > hiddenPosition.X); @@ -116,7 +116,7 @@ public sealed class ProjectileControllerTests | PhysicsStateFlags.Hidden | PhysicsStateFlags.IgnoreCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 1.1, 1, 1)); + record, record.FinalPhysicsState, 1.1, 1, 1)); remote.Interp.Enqueue( entity.Position + Vector3.UnitX, heading: 0f, @@ -148,14 +148,27 @@ public sealed class ProjectileControllerTests var fixture = new Fixture(loadInitialLandblock: true); LiveEntityRecord record = fixture.Spawn(instance: 1); WorldEntity entity = record.WorldEntity!; + fixture.Engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + state: (uint)MissileState, + seedCellId: CellA, + isStatic: false); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0)); ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; Assert.True(fixture.Controller.ApplyAuthoritativePosition( - Guid, - worldPosition: new Vector3(202f, 10f, 5f), + record, + worldPosition: new Vector3(10f, 202f, 5f), cellLocalPosition: new Vector3(10f, 10f, 5f), orientation: Quaternion.Identity, + velocity: new Vector3(1f, 2f, 3f), fullCellId: CellB, currentTime: 2.1, liveCenterX: 1, @@ -177,24 +190,36 @@ public sealed class ProjectileControllerTests Assert.False(runtime.Body.IsActive); fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + + Assert.True(record.IsSpatiallyVisible); + Assert.True(runtime.Body.InWorld); + Assert.True(runtime.Body.IsActive); + Assert.Equal(pendingPosition, entity.Position); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + ShadowEntry restoredShadow = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(entity.Id, restoredShadow.EntityId); + Assert.Equal(pendingPosition, restoredShadow.Position); + fixture.Controller.Tick( 2.5, liveCenterX: 1, liveCenterY: 1, - playerWorldPosition: null); + playerWorldPosition: pendingPosition); Assert.True(record.IsSpatiallyVisible); Assert.Same(entity, record.WorldEntity); Assert.Same(runtime, record.ProjectileRuntime); Assert.Equal(1, fixture.Resources.Registers); - Assert.Equal(pendingPosition, entity.Position); + Vector3 firstReentryTick = entity.Position; + Assert.True(firstReentryTick.X > pendingPosition.X); fixture.Controller.Tick( 2.6, liveCenterX: 1, liveCenterY: 1, - playerWorldPosition: null); - Assert.True(entity.Position.X > pendingPosition.X); + playerWorldPosition: pendingPosition); + Assert.True(entity.Position.X > firstReentryTick.X); } [Fact] @@ -229,7 +254,15 @@ public sealed class ProjectileControllerTests fixture.Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); Assert.Single(fixture.Live.SpatialProjectileRuntimes); - fixture.Controller.Tick(1.1, 1, 1, playerWorldPosition: null); + Assert.True(record.PhysicsBody.InWorld); + Assert.True(record.PhysicsBody.IsActive); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + + fixture.Controller.Tick( + 1.1, + 1, + 1, + playerWorldPosition: record.WorldEntity!.Position); Assert.True(record.PhysicsBody.InWorld); Assert.True(record.PhysicsBody.IsActive); @@ -304,9 +337,10 @@ public sealed class ProjectileControllerTests playerWorldPosition: null); Assert.Equal(0xAAB40001u, record.FullCellId); + Assert.Equal(0xAAB40001u, record.PhysicsBody!.CellPosition.ObjCellId); Assert.False(record.IsSpatiallyVisible); Assert.True(record.IsSpatiallyProjected); - Assert.False(record.PhysicsBody!.InWorld); + Assert.False(record.PhysicsBody.InWorld); Assert.False(record.PhysicsBody.IsActive); Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(startCell)); Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u)); @@ -315,13 +349,14 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 0.06, 0xA9, 0xB4)); + record, record.FinalPhysicsState, 0.06, 0xA9, 0xB4)); fixture.Spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); fixture.Controller.Tick(0.07, 0xA9, 0xB4, playerWorldPosition: null); Assert.True(record.IsSpatiallyVisible); + Assert.Equal(0xAAB40001u, record.PhysicsBody.CellPosition.ObjCellId); Assert.True(record.PhysicsBody.InWorld); - Assert.False(record.PhysicsBody.IsActive); + Assert.True(record.PhysicsBody.IsActive); Assert.Contains( fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u), entry => entry.EntityId == entity.Id); @@ -336,7 +371,7 @@ public sealed class ProjectileControllerTests PhysicsBody body = record.PhysicsBody!; Assert.True(fixture.Controller.ApplyAuthoritativeVector( - Guid, + record, new Vector3(7f, 8f, 9f), new Vector3(0f, 0f, 2f), currentTime: 4.5)); @@ -344,11 +379,13 @@ public sealed class ProjectileControllerTests Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega); var correction = new Vector3(30f, 31f, 32f); + var correctedVelocity = new Vector3(3f, 4f, 5f); Assert.True(fixture.Controller.ApplyAuthoritativePosition( - Guid, + record, correction, new Vector3(30f, 31f, 32f), Quaternion.Identity, + correctedVelocity, CellA, currentTime: 5.0, liveCenterX: 1, @@ -357,7 +394,95 @@ public sealed class ProjectileControllerTests Assert.Same(body, record.PhysicsBody); Assert.Equal(correction, body.Position); Assert.Equal(correction, record.WorldEntity!.Position); - Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity); + Assert.Equal(correctedVelocity, body.Velocity); + + // InboundPhysicsStateController normalizes an absent PositionPack + // velocity to zero before this contract is called. + Assert.True(fixture.Controller.ApplyAuthoritativePosition( + record, + correction, + new Vector3(30f, 31f, 32f), + Quaternion.Identity, + Vector3.Zero, + CellA, + currentTime: 5.1, + liveCenterX: 1, + liveCenterY: 1)); + Assert.Equal(Vector3.Zero, body.Velocity); + } + + [Theory] + [InlineData(AuthoritativeMutation.Vector)] + [InlineData(AuthoritativeMutation.Position)] + [InlineData(AuthoritativeMutation.State)] + public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction( + AuthoritativeMutation mutation) + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1)); + PhysicsBody body = record.PhysicsBody!; + Assert.True(fixture.Controller.TryBeginQuantum( + record, + quantum: 0.05f, + out ProjectileController.QuantumStep step)); + + Vector3 correctedPosition = new(30f, 31f, 32f); + Vector3 correctedVelocity = new(7f, 8f, 9f); + Vector3 correctedOmega = new(0f, 0f, 2f); + PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity; + switch (mutation) + { + case AuthoritativeMutation.Vector: + Assert.True(fixture.Controller.ApplyAuthoritativeVector( + record, + correctedVelocity, + correctedOmega, + currentTime: 1.02)); + break; + + case AuthoritativeMutation.Position: + Assert.True(fixture.Controller.ApplyAuthoritativePosition( + record, + correctedPosition, + correctedPosition, + Quaternion.Identity, + correctedVelocity, + CellA, + currentTime: 1.02, + liveCenterX: 1, + liveCenterY: 1)); + break; + + case AuthoritativeMutation.State: + record.FinalPhysicsState = correctedState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + record, + correctedState, + currentTime: 1.02, + liveCenterX: 1, + liveCenterY: 1)); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(mutation)); + } + + Assert.False(fixture.Controller.CompleteQuantum(step, 1, 1)); + switch (mutation) + { + case AuthoritativeMutation.Vector: + Assert.Equal(correctedVelocity, body.Velocity); + Assert.Equal(correctedOmega, body.Omega); + break; + case AuthoritativeMutation.Position: + Assert.Equal(correctedPosition, body.Position); + Assert.Equal(correctedVelocity, body.Velocity); + break; + case AuthoritativeMutation.State: + Assert.Equal(correctedState, body.State); + break; + } } [Fact] @@ -392,7 +517,7 @@ public sealed class ProjectileControllerTests PhysicsBody body = runtime.Body; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, + record, PhysicsStateFlags.ReportCollisions, currentTime: 1.1, liveCenterX: 1, @@ -411,7 +536,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, + record, MissileState, currentTime: 1.3, liveCenterX: 1, @@ -452,7 +577,7 @@ public sealed class ProjectileControllerTests StateSequence: 2), out _)); Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 0.06, 0xA9, 0xB4)); + record, record.FinalPhysicsState, 0.06, 0xA9, 0xB4)); Assert.Equal(destinationCell, record.FullCellId); Assert.True(fixture.Live.TryApplyState( new SetState.Parsed( @@ -462,7 +587,7 @@ public sealed class ProjectileControllerTests StateSequence: 3), out _)); Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, 0.07, 0xA9, 0xB4)); + record, MissileState, 0.07, 0xA9, 0xB4)); Assert.Same(runtime, record.ProjectileRuntime); Assert.Same(runtime.Body, record.PhysicsBody); @@ -481,7 +606,7 @@ public sealed class ProjectileControllerTests body.LastUpdateTime = 1.0; Assert.True(fixture.Controller.ApplyAuthoritativeVector( - Guid, + record, new Vector3(100f, 0f, 0f), Vector3.Zero, currentTime: 20.0)); @@ -506,7 +631,7 @@ public sealed class ProjectileControllerTests body.TransientState &= ~TransientStateFlags.Active; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, + record, MissileState | PhysicsStateFlags.Inelastic, currentTime: 2.0, liveCenterX: 1, @@ -516,11 +641,11 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 2.1, 1, 1)); + record, record.FinalPhysicsState, 2.1, 1, 1)); Assert.False(body.IsActive); record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, 2.2, 1, 1)); + record, MissileState, 2.2, 1, 1)); Assert.False(body.IsActive); } @@ -533,10 +658,10 @@ public sealed class ProjectileControllerTests PhysicsBody body = record.PhysicsBody!; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 1.01, 1, 1)); + record, record.FinalPhysicsState, 1.01, 1, 1)); Assert.True(fixture.Controller.ApplyAuthoritativeVector( - Guid, + record, new Vector3(100f, 0f, 0f), new Vector3(0f, 0f, 3f), 1.02)); @@ -561,11 +686,11 @@ public sealed class ProjectileControllerTests fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body)); record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 2.0, 1, 1)); + record, record.FinalPhysicsState, 2.0, 1, 1)); Vector3 before = body.Velocity; Assert.False(fixture.Controller.ApplyAuthoritativeVector( - Guid, + record, new Vector3(0f, 0f, 12f), new Vector3(0f, 0f, 2f), 3.0)); @@ -586,14 +711,14 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 2.0, 1, 1)); + record, record.FinalPhysicsState, 2.0, 1, 1)); body.TransientState |= TransientStateFlags.Active; body.LastUpdateTime = 9.5; // generic remote interval used another clock owner body.set_velocity(new Vector3(40f, 0f, 0f)); record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, 10.0, 1, 1)); + record, MissileState, 10.0, 1, 1)); Assert.Equal(10.0, body.LastUpdateTime, 8); Assert.True(body.IsActive); @@ -611,13 +736,13 @@ public sealed class ProjectileControllerTests PhysicsBody body = record.PhysicsBody!; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 2.0, 1, 1)); + record, record.FinalPhysicsState, 2.0, 1, 1)); PhysicsStateFlags stateBefore = body.State; double clockBefore = body.LastUpdateTime; record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, double.NaN, 1, 1)); + record, MissileState, double.NaN, 1, 1)); Assert.Equal(record.FinalPhysicsState, body.State); Assert.NotEqual(stateBefore, body.State); @@ -647,6 +772,41 @@ public sealed class ProjectileControllerTests Assert.Equal(entity.Position, after.Translation); } + [Fact] + public void RootPoseCallbackGuidReuse_MakesQuantumReportSuperseded() + { + Fixture? fixture = null; + LiveEntityRecord? first = null; + LiveEntityRecord? replacement = null; + bool replaced = false; + fixture = new Fixture( + publishRootPose: _ => + { + if (replaced) + return; + replaced = true; + Assert.NotNull(first); + Assert.True(fixture!.Live.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, first!.Generation), + isLocalPlayer: false)); + replacement = fixture.Spawn(instance: 2); + }); + first = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0, 1, 1)); + + Assert.False(fixture.Controller.AdvanceQuantum( + first, + quantum: 0.05f, + liveCenterX: 1, + liveCenterY: 1)); + + Assert.True(replaced); + Assert.NotNull(replacement); + Assert.True(fixture.Live.TryGetRecord(Guid, out LiveEntityRecord current)); + Assert.Same(replacement, current); + Assert.Null(current.ProjectileRuntime); + } + [Fact] public void Tick_PartArrayUsesRetailNinetySixUnitActivationBoundary() { @@ -699,17 +859,18 @@ public sealed class ProjectileControllerTests Vector3 position = body.Position; Assert.True(fixture.Controller.ApplyAuthoritativeVector( - Guid, + record, new Vector3(float.NaN, 0f, 0f), Vector3.Zero, currentTime: 2.0)); Assert.Equal(velocity, body.Velocity); Assert.True(fixture.Controller.ApplyAuthoritativePosition( - Guid, + record, new Vector3(float.PositiveInfinity, 0f, 0f), new Vector3(float.PositiveInfinity, 0f, 0f), Quaternion.Identity, + Vector3.Zero, CellA, currentTime: 2.1, liveCenterX: 1, @@ -772,7 +933,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, 2.0, 1, 1)); + record, MissileState, 2.0, 1, 1)); Assert.NotNull(record.ProjectileRuntime); Assert.True(float.IsFinite(record.PhysicsBody!.Velocity.X)); Assert.True(float.IsFinite(record.PhysicsBody.Velocity.Y)); @@ -793,7 +954,7 @@ public sealed class ProjectileControllerTests PhysicsStateFlags.Gravity | PhysicsStateFlags.IgnoreCollisions; record.FinalPhysicsState = ordinaryState; Assert.False(fixture.Controller.ApplyAuthoritativeState( - Guid, ordinaryState, 1.0, 1, 1)); + record, ordinaryState, 1.0, 1, 1)); Assert.Equal(ordinaryState, remote.Body.State); Assert.Null(record.ProjectileRuntime); @@ -803,7 +964,7 @@ public sealed class ProjectileControllerTests ordinaryState | PhysicsStateFlags.Missile; record.FinalPhysicsState = unsupportedMissile; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, unsupportedMissile, 1.1, 1, 1)); + record, unsupportedMissile, 1.1, 1, 1)); Assert.Equal(unsupportedMissile, remote.Body.State); Assert.Null(record.ProjectileRuntime); @@ -819,7 +980,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, double.NaN, 1, 1)); + record, MissileState, double.NaN, 1, 1)); Assert.NotNull(record.ProjectileRuntime); Assert.Equal(MissileState, record.PhysicsBody!.State); @@ -827,7 +988,7 @@ public sealed class ProjectileControllerTests ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, 0.1, 1, 1)); + record, MissileState, 0.1, 1, 1)); Assert.Same(runtime, record.ProjectileRuntime); } @@ -843,7 +1004,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, double.PositiveInfinity, 1, 1)); + record, MissileState, double.PositiveInfinity, 1, 1)); Assert.NotNull(record.ProjectileRuntime); Assert.Same(remote.Body, record.ProjectileRuntime!.Body); @@ -922,7 +1083,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, + record, MissileState, currentTime: 1.2, liveCenterX: 0xA9, @@ -941,7 +1102,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, record.FinalPhysicsState, 1.5, 0xA9, 0xB4)); + record, record.FinalPhysicsState, 1.5, 0xA9, 0xB4)); Assert.Equal(destinationCell, remote.CellId); Assert.Equal(destinationCell, record.FullCellId); @@ -960,7 +1121,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, 1.7, 0xA9, 0xB4)); + record, MissileState, 1.7, 0xA9, 0xB4)); Assert.Equal(startCell, record.FullCellId); Assert.Equal(startCell, remote.CellId); Assert.Equal(startCell, body.CellPosition.ObjCellId); @@ -986,7 +1147,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, 1.0, 1, 1)); + record, MissileState, 1.0, 1, 1)); Assert.NotNull(record.ProjectileRuntime); Assert.Same(remote.Body, record.ProjectileRuntime!.Body); @@ -1010,7 +1171,7 @@ public sealed class ProjectileControllerTests record.FinalPhysicsState = MissileState; Assert.True(fixture.Controller.ApplyAuthoritativeState( - Guid, MissileState, 1.0, 1, 1)); + record, MissileState, 1.0, 1, 1)); Assert.Null(record.ProjectileRuntime); Assert.Same(remote.Body, record.PhysicsBody); @@ -1113,15 +1274,22 @@ public sealed class ProjectileControllerTests _ => throw new InvalidOperationException("re-entry recreated the entity"))); Assert.True(fixture.Controller.ApplyAuthoritativePosition( - Guid, + record, worldPosition: new Vector3(12f, 10f, 5f), cellLocalPosition: new Vector3(12f, 10f, 5f), orientation: Quaternion.Identity, + velocity: Vector3.Zero, fullCellId: CellA, currentTime: 10.0, liveCenterX: 1, liveCenterY: 1)); - Assert.Equal(10.0, record.PhysicsBody!.LastUpdateTime, 8); + // The incarnation-stable RetailObjectQuantumClock is canonical after + // the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute + // clock mirror and need not equal the packet receipt time once the + // visibility edge has already performed prepare_to_enter_world. + Assert.Equal(0d, record.ObjectClock.PendingSeconds, 8); + Assert.True(record.ObjectClock.IsActive); + Assert.True(record.PhysicsBody!.IsActive); Assert.Contains( fixture.Engine.ShadowObjects.GetObjectsInCell(CellA), entry => entry.EntityId == entity.Id); @@ -1130,8 +1298,14 @@ public sealed class ProjectileControllerTests 10.1, liveCenterX: 1, liveCenterY: 1, - playerWorldPosition: null); - Assert.Equal(13f, entity.Position.X, 3); + playerWorldPosition: entity.Position); + Assert.Equal(12f, entity.Position.X, 3); + fixture.Controller.Tick( + 10.2, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: entity.Position); + Assert.Equal(12f, entity.Position.X, 3); } [Fact] @@ -1148,14 +1322,94 @@ public sealed class ProjectileControllerTests isLocalPlayer: false)); LiveEntityRecord second = fixture.Spawn(instance: 8); Assert.True(fixture.Controller.TryBind(second, ProjectileSetup(), 2.0)); + ILiveEntityProjectileRuntime secondRuntime = second.ProjectileRuntime!; + + Assert.False(fixture.Controller.TryBind(first, ProjectileSetup(), 3.0)); Assert.NotSame(firstEntity, second.WorldEntity); Assert.NotSame(firstRuntime, second.ProjectileRuntime); + Assert.Same(secondRuntime, second.ProjectileRuntime); Assert.Equal(1, fixture.Controller.Count); Assert.Equal(2, fixture.Resources.Registers); Assert.Equal(1, fixture.Resources.Unregisters); } + [Fact] + public void TryBind_ReentrantVisibilityGuidReuseNeverBindsOldBodyToReplacement() + { + var fixture = new Fixture(); + LiveEntityRecord first = fixture.Spawn( + instance: 7, + cellId: CellA, + worldPosition: new Vector3(10f, 202f, 5f), + cellLocalPosition: new Vector3(10f, 202f, 5f)); + LiveEntityRecord? replacement = null; + bool replaced = false; + fixture.Live.ProjectionVisibilityChanged += (record, visible) => + { + if (replaced || visible || !ReferenceEquals(record, first)) + return; + replaced = true; + Assert.True(fixture.Live.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, InstanceSequence: 7), + isLocalPlayer: false)); + replacement = fixture.Spawn(instance: 8); + }; + + Assert.False(fixture.Controller.TryBind( + first, + ProjectileSetup(), + currentTime: 1.0, + liveCenterX: 1, + liveCenterY: 1)); + + Assert.NotNull(replacement); + Assert.True(fixture.Live.TryGetRecord(Guid, out var current)); + Assert.Same(replacement, current); + Assert.Null(current.ProjectileRuntime); + Assert.Null(current.PhysicsBody); + Assert.Equal(0, fixture.Controller.Count); + } + + [Fact] + public void AuthoritativePosition_ReentrantGuidReuseStopsOldPostRebucketWork() + { + var fixture = new Fixture(); + LiveEntityRecord first = fixture.Spawn(instance: 7); + Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0, 1, 1)); + LiveEntityRecord? replacement = null; + bool replaced = false; + fixture.Live.ProjectionVisibilityChanged += (record, visible) => + { + if (replaced || visible || !ReferenceEquals(record, first)) + return; + replaced = true; + Assert.True(fixture.Live.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, InstanceSequence: 7), + isLocalPlayer: false)); + replacement = fixture.Spawn(instance: 8); + }; + + Assert.True(fixture.Controller.ApplyAuthoritativePosition( + first, + worldPosition: new Vector3(10f, 202f, 5f), + cellLocalPosition: new Vector3(10f, 10f, 5f), + orientation: Quaternion.Identity, + velocity: new Vector3(44f, 0f, 0f), + fullCellId: CellB, + currentTime: 1.1, + liveCenterX: 1, + liveCenterY: 1)); + + Assert.NotNull(replacement); + Assert.True(fixture.Live.TryGetRecord(Guid, out var current)); + Assert.Same(replacement, current); + Assert.Null(current.ProjectileRuntime); + Assert.Null(current.PhysicsBody); + Assert.Equal(new Vector3(10f, 10f, 5f), current.WorldEntity!.Position); + Assert.Equal(0, fixture.Controller.Count); + } + [Fact] public void MissingRetailSphere_FailsWithoutFabricatingCollisionBody() { @@ -1175,7 +1429,8 @@ public sealed class ProjectileControllerTests { internal Fixture( bool loadInitialLandblock = true, - PhysicsEngine? physicsEngine = null) + PhysicsEngine? physicsEngine = null, + Action? publishRootPose = null) { if (loadInitialLandblock) Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); @@ -1185,7 +1440,8 @@ public sealed class ProjectileControllerTests Live, Engine, _ => ResolvedSetup, - entity => Poses.UpdateRoot(entity)); + publishRootPose ?? (entity => Poses.UpdateRoot(entity)), + () => LiveCenter); } internal GpuWorldState Spatial { get; } = new(); @@ -1194,6 +1450,7 @@ public sealed class ProjectileControllerTests internal PhysicsEngine Engine { get; } internal EntityEffectPoseRegistry Poses { get; } = new(); internal Setup ResolvedSetup { get; set; } = ProjectileSetup(); + internal (int X, int Y) LiveCenter { get; set; } = (1, 1); internal ProjectileController Controller { get; } internal LiveEntityRecord Spawn( @@ -1221,6 +1478,7 @@ public sealed class ProjectileControllerTests elasticity); LiveEntityRegistrationResult registration = Live.RegisterLiveEntity(spawn); Assert.NotNull(registration.Record); + registration.Record!.HasPartArray = true; WorldEntity? entity = Live.MaterializeLiveEntity( Guid, cellId, @@ -1242,6 +1500,13 @@ public sealed class ProjectileControllerTests } } + public enum AuthoritativeMutation + { + Vector, + Position, + State, + } + private static Setup ProjectileSetup(float radius = 0.102f) => new() { Spheres = diff --git a/tests/AcDream.App.Tests/Physics/RemoteInboundMotionDispatcherTests.cs b/tests/AcDream.App.Tests/Physics/RemoteInboundMotionDispatcherTests.cs new file mode 100644 index 00000000..44e80c16 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RemoteInboundMotionDispatcherTests.cs @@ -0,0 +1,171 @@ +using AcDream.App.Physics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; + +namespace AcDream.App.Tests.Physics; + +public sealed class RemoteInboundMotionDispatcherTests +{ + [Fact] + public void AnimationlessTypeZero_UsesCompleteRetailPacketFunnel() + { + var body = new PhysicsBody + { + TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.Active, + }; + var motion = new MotionInterpreter(body); + var movement = new MovementManager(motion); + var calls = new List(); + motion.InterruptCurrentMovement = () => calls.Add("interrupt"); + motion.UnstickFromObject = () => calls.Add("unstick"); + var dispatcher = new RemoteInboundMotionDispatcher( + (owner, cellId, update) => + { + Assert.Same(movement, owner); + Assert.Equal(0x01010001u, cellId); + calls.Add("route"); + return false; + }, + (host, stickyGuid) => + { + Assert.Null(host); + Assert.Equal(0x70000099u, stickyGuid); + calls.Add("stick"); + }); + var wire = new CreateObject.ServerMotionState( + Stance: 0x3F, + ForwardCommand: 0x0007, + ForwardSpeed: 2.5f, + SideStepCommand: 0x000F, + SideStepSpeed: 0.75f, + TurnCommand: 0x000D, + TurnSpeed: 1.25f, + MovementType: 0, + StickyObjectGuid: 0x70000099u, + StandingLongJump: true); + var update = new WorldSession.EntityMotionUpdate( + Guid: 0x70000001u, + MotionState: wire, + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 3, + IsAutonomous: false); + + RemoteInboundMotionDispatchResult result = dispatcher.Apply( + update, + movement, + animationSink: null, + host: null, + cellId: 0x01010001u, + fallbackForwardClass: 0x41000000u); + + Assert.False(result.RoutedMoveTo); + Assert.True(result.AppliedInterpretedState); + Assert.True(result.ForwardCommandChanged); + Assert.Equal(0x8000003Fu, motion.InterpretedState.CurrentStyle); + Assert.Equal(0x44000007u, motion.InterpretedState.ForwardCommand); + Assert.Equal(2.5f, motion.InterpretedState.ForwardSpeed); + Assert.Equal(0x6500000Fu, motion.InterpretedState.SideStepCommand); + Assert.Equal(0.75f, motion.InterpretedState.SideStepSpeed); + Assert.Equal(0x6500000Du, motion.InterpretedState.TurnCommand); + Assert.Equal(1.25f, motion.InterpretedState.TurnSpeed); + Assert.True(motion.StandingLongJump); + Assert.Equal("interrupt", calls[0]); + Assert.Equal("unstick", calls[1]); + Assert.Contains("route", calls); + Assert.Equal("stick", calls[^1]); + } + + [Fact] + public void UnknownMovementType_RunsHeadButDoesNotEnterTypeZeroFunnel() + { + var body = new PhysicsBody(); + var motion = new MotionInterpreter(body); + var movement = new MovementManager(motion); + int interrupts = 0; + int unsticks = 0; + motion.InterruptCurrentMovement = () => interrupts++; + motion.UnstickFromObject = () => unsticks++; + var dispatcher = new RemoteInboundMotionDispatcher( + (_, _, _) => false, + (_, _) => throw new InvalidOperationException( + "Unknown movement type reached the type-zero tail.")); + var update = new WorldSession.EntityMotionUpdate( + Guid: 0x70000001u, + MotionState: new CreateObject.ServerMotionState( + Stance: 0x3E, + ForwardCommand: 0x0007, + MovementType: 5), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 3, + IsAutonomous: false); + + RemoteInboundMotionDispatchResult result = dispatcher.Apply( + update, + movement, + animationSink: null, + host: null, + cellId: 0x01010001u, + fallbackForwardClass: 0x41000000u); + + Assert.False(result.RoutedMoveTo); + Assert.False(result.AppliedInterpretedState); + Assert.True(interrupts >= 1); + Assert.Equal(1, unsticks); + Assert.Equal(0x41000003u, motion.InterpretedState.ForwardCommand); + } + + [Fact] + public void SupersededDuringInterrupt_StopsBeforeEveryLaterPacketSideEffect() + { + var body = new PhysicsBody(); + var motion = new MotionInterpreter(body); + var movement = new MovementManager(motion); + bool current = true; + int unsticks = 0; + int routes = 0; + int sticks = 0; + motion.InterruptCurrentMovement = () => current = false; + motion.UnstickFromObject = () => unsticks++; + var dispatcher = new RemoteInboundMotionDispatcher( + (_, _, _) => + { + routes++; + return false; + }, + (_, _) => sticks++); + var update = new WorldSession.EntityMotionUpdate( + Guid: 0x70000001u, + MotionState: new CreateObject.ServerMotionState( + Stance: 0x3F, + ForwardCommand: 0x0007, + MovementType: 0, + StickyObjectGuid: 0x70000099u, + StandingLongJump: true), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 3, + IsAutonomous: false); + + RemoteInboundMotionDispatchResult result = dispatcher.Apply( + update, + movement, + animationSink: null, + host: null, + cellId: 0x01010001u, + fallbackForwardClass: 0x41000000u, + isCurrent: () => current); + + Assert.True(result.Superseded); + Assert.False(result.AppliedInterpretedState); + Assert.Equal(0, unsticks); + Assert.Equal(0, routes); + Assert.Equal(0, sticks); + Assert.False(motion.StandingLongJump); + } +} diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 9c34eac7..0f984d79 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -7,6 +7,7 @@ using AcDream.App.World; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; using AcDream.Core.World; using DatReaderWriter.DBObjs; @@ -14,6 +15,296 @@ namespace AcDream.App.Tests.Physics; public sealed class RemotePhysicsUpdaterTests { + [Fact] + public void ShadowPoseGate_TracksTranslationAndSignInvariantOrientation() + { + Quaternion turn = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f); + + Assert.False(RemotePhysicsUpdater.ShouldSynchronizeShadowPose( + Vector3.Zero, + Quaternion.Identity, + Vector3.Zero, + Quaternion.Identity)); + Assert.False(RemotePhysicsUpdater.ShouldSynchronizeShadowPose( + Vector3.Zero, + turn, + Vector3.Zero, + new Quaternion(-turn.X, -turn.Y, -turn.Z, -turn.W))); + Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadowPose( + new Vector3(0.02f, 0f, 0f), + Quaternion.Identity, + Vector3.Zero, + Quaternion.Identity)); + Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadowPose( + Vector3.Zero, + turn, + Vector3.Zero, + Quaternion.Identity)); + Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadow( + cellChanged: true, + Vector3.Zero, + Quaternion.Identity, + Vector3.Zero, + Quaternion.Identity)); + } + + [Fact] + public void Tick_InPlaceCompleteRootTurn_UpdatesOffsetCollisionShadow() + { + const uint cellId = 0x01010001u; + Quaternion turn = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f); + var engine = new PhysicsEngine(); + var entity = new WorldEntity + { + Id = 13u, + ServerGuid = 0x80000013u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = cellId, + }; + var motion = new GameWindow.RemoteMotion + { + LastShadowSyncPos = Vector3.Zero, + LastShadowSyncOrientation = Quaternion.Identity, + }; + motion.Body.Position = Vector3.Zero; + motion.Body.Orientation = Quaternion.Identity; + motion.CellId = cellId; + engine.ShadowObjects.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + [ + new ShadowShape( + 0x01000001u, + new Vector3(1f, 0f, 0f), + Quaternion.Identity, + Scale: 1f, + CollisionType: ShadowCollisionType.Cylinder, + Radius: 0.25f, + CylHeight: 1f), + ], + state: (uint)PhysicsStateFlags.ReportCollisions, + flags: EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + seedCellId: cellId); + var updater = new RemotePhysicsUpdater( + engine, + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + + updater.Tick( + motion, + entity, + objectScale: 1f, + sequencer: null, + animationForVelocityCycle: null, + dt: 0.1f, + new MotionDeltaFrame { Orientation = turn }, + liveCenterX: 1, + liveCenterY: 1); + + ShadowEntry entry = Assert.Single( + engine.ShadowObjects.AllEntriesForDebug(), + candidate => candidate.EntityId == entity.Id); + Assert.Equal(0f, entry.Position.X, 3); + Assert.Equal(1f, entry.Position.Y, 3); + Assert.InRange( + MathF.Abs(Quaternion.Dot( + turn, + Quaternion.Normalize(motion.LastShadowSyncOrientation))), + 0.99999f, + 1.00001f); + } + + [Fact] + public void Tick_ComposesCompleteSequenceFrameWithoutOmegaSideChannel() + { + var entity = new WorldEntity + { + Id = 10u, + ServerGuid = 0x80000010u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 20f, 30f), + Rotation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + -MathF.PI / 2f), + MeshRefs = Array.Empty(), + }; + var animated = new GameWindow.AnimatedEntity + { + Entity = entity, + Setup = new Setup(), + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = Array.Empty(), + PartAvailability = Array.Empty(), + }; + var motion = new GameWindow.RemoteMotion(); + motion.Body.Position = entity.Position; + motion.Body.Orientation = entity.Rotation; + motion.Body.TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.Active; + var sequenceFrame = new MotionDeltaFrame + { + Origin = new Vector3(0f, 0.1f, 0f), + Orientation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f), + }; + var updater = new RemotePhysicsUpdater( + new PhysicsEngine(), + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + + updater.Tick( + motion, + animated, + dt: 0.1f, + sequenceFrame, + liveCenterX: 0, + liveCenterY: 0); + + // Current body faces east, so the sequence's local +Y origin moves + // east before its quarter-turn is composed. No ObservedOmega/manual + // integrator participates. + Assert.Equal(10.1f, motion.Body.Position.X, 3); + Assert.Equal(20f, motion.Body.Position.Y, 3); + Assert.InRange( + MathF.Abs(Quaternion.Dot( + Quaternion.Identity, + Quaternion.Normalize(motion.Body.Orientation))), + 0.99999f, + 1.00001f); + Assert.Equal(motion.Body.Orientation, entity.Rotation); + } + + [Fact] + public void Tick_HostlessAirborneRemote_SuppressesOriginButPreservesRootOrientation() + { + Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f); + Quaternion rootTurn = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f); + var entity = new WorldEntity + { + Id = 11u, + ServerGuid = 0x80000011u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 20f, 30f), + Rotation = initial, + MeshRefs = Array.Empty(), + }; + var animated = new GameWindow.AnimatedEntity + { + Entity = entity, + Setup = new Setup(), + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = Array.Empty(), + PartAvailability = Array.Empty(), + }; + var motion = new GameWindow.RemoteMotion + { + Airborne = true, + }; + motion.Body.Position = entity.Position; + motion.Body.Orientation = initial; + motion.Body.TransientState = TransientStateFlags.Active; + var root = new MotionDeltaFrame + { + Origin = new Vector3(0f, 10f, 0f), + Orientation = rootTurn, + }; + var updater = new RemotePhysicsUpdater( + new PhysicsEngine(), + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + + updater.Tick(motion, animated, 0.1f, root, 0, 0); + + Assert.Equal(new Vector3(10f, 20f, 30f), motion.Body.Position); + Quaternion expected = Quaternion.Normalize(initial * rootTurn); + Assert.InRange( + MathF.Abs(Quaternion.Dot( + expected, + Quaternion.Normalize(motion.Body.Orientation))), + 0.99999f, + 1.00001f); + } + + [Fact] + public void Tick_ActiveInterpolation_ReplacesRootWithCompleteTargetFrame() + { + Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f); + Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f); + var entity = new WorldEntity + { + Id = 12u, + ServerGuid = 0x80000012u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 20f, 30f), + Rotation = initial, + MeshRefs = Array.Empty(), + }; + var animated = new GameWindow.AnimatedEntity + { + Entity = entity, + Setup = new Setup(), + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = Array.Empty(), + PartAvailability = Array.Empty(), + }; + var motion = new GameWindow.RemoteMotion(); + motion.Body.Position = entity.Position; + motion.Body.Orientation = initial; + motion.Interp.Enqueue( + entity.Position + Vector3.UnitX, + target, + isMovingTo: false, + currentBodyPosition: entity.Position); + var root = new MotionDeltaFrame + { + Origin = new Vector3(0f, 10f, 0f), + Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.7f), + }; + var updater = new RemotePhysicsUpdater( + new PhysicsEngine(), + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + + updater.Tick(motion, animated, 0.1f, root, 0, 0); + + // InterpolationManager::adjust_offset clamps the 1 m correction to + // maxSpeed (8 m/s) * 0.1 s = 0.8 m and replaces the authored 10 m + // root origin rather than adding to it. + Assert.InRange(motion.Body.Position.X, 10.79f, 10.81f); + Assert.InRange( + MathF.Abs(Quaternion.Dot( + Quaternion.Normalize(target), + Quaternion.Normalize(motion.Body.Orientation))), + 0.99999f, + 1.00001f); + Assert.Equal(motion.Body.Orientation, entity.Rotation); + } + [Fact] public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics() { @@ -74,15 +365,55 @@ public sealed class RemotePhysicsUpdaterTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), }; - int partArrayTailCalls = 0; + AnimationSequencer sequencer = CreateSequencer(); + sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0); updater.TickHidden( motion, entity, 0.1f, - () => partArrayTailCalls++); + sequencer.Manager); - Assert.Equal(1, partArrayTailCalls); + Assert.Empty(sequencer.Manager.PendingAnimations); + } + + [Fact] + public void TickHidden_ProcessesPendingHooksBeforeManagerTail() + { + var updater = new RemotePhysicsUpdater( + new PhysicsEngine(), + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + var motion = new GameWindow.RemoteMotion(); + motion.Body.Orientation = Quaternion.Identity; + var entity = new WorldEntity + { + Id = 3u, + ServerGuid = 0x70000003u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + AnimationSequencer sequencer = CreateSequencer(); + sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0); + var order = new List(); + + updater.TickHidden( + motion, + entity, + 0.1f, + partArrayHandleMovement: sequencer.Manager, + processAnimationHooks: (_, observed) => + { + Assert.Same(sequencer, observed); + Assert.NotEmpty(sequencer.Manager.PendingAnimations); + order.Add("hooks"); + }, + sequencer); + + Assert.Equal(["hooks"], order); + Assert.Empty(sequencer.Manager.PendingAnimations); } [Fact] @@ -133,7 +464,7 @@ public sealed class RemotePhysicsUpdaterTests } [Fact] - public void TickHidden_LandingSynchronizesRemoteAirborneState() + public void TickHidden_OutOfContactDoesNotInterpolateOrInventLanding() { var engine = BuildBoundaryEngine(); var updater = new RemotePhysicsUpdater( @@ -165,9 +496,190 @@ public sealed class RemotePhysicsUpdaterTests updater.TickHidden(motion, entity, 0.1f); - Assert.True(motion.Body.InContact); - Assert.True(motion.Body.OnWalkable); - Assert.False(motion.Airborne); + Assert.False(motion.Body.InContact); + Assert.False(motion.Body.OnWalkable); + Assert.True(motion.Airborne); + Assert.Equal(new Vector3(10f, 10f, 50f), motion.Body.Position); + } + + [Fact] + public void Tick_CrossCellIntoPending_CommitsRootAndSuspendsShadowBeforeReturning() + { + using var fixture = new BoundaryRemoteFixture(); + Vector3 sourceShadowPosition = fixture.Remote.LastShadowSyncPos; + ulong clockEpoch = fixture.Record.ObjectClockEpoch; + + bool current = fixture.Updater.Tick( + fixture.Remote, + fixture.Entity, + objectScale: 1f, + sequencer: null, + animationForVelocityCycle: null, + dt: 2f, + rootMotionLocalFrame: new MotionDeltaFrame(), + liveCenterX: 0, + liveCenterY: 0, + ownerRuntime: fixture.Live, + ownerRecord: fixture.Record, + ownerClockEpoch: clockEpoch); + + Assert.False(current); + Assert.Equal(BoundaryRemoteFixture.DestinationCell, fixture.Record.FullCellId); + Assert.False(fixture.Record.IsSpatiallyVisible); + Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position); + Assert.Equal(BoundaryRemoteFixture.DestinationCell, fixture.Entity.ParentCellId); + Assert.True(fixture.Entity.Position.X > 192f); + Assert.Equal(sourceShadowPosition, fixture.Remote.LastShadowSyncPos); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount); + + fixture.HydrateDestination(); + + Assert.True(fixture.Record.IsSpatiallyVisible); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount); + ShadowEntry restored = Assert.Single( + fixture.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(fixture.Entity.Id, restored.EntityId); + Assert.Equal(fixture.Entity.Position, restored.Position); + } + + [Fact] + public void AuthoritativeShadowPublisher_AlreadyPendingCannotResurrectShadow() + { + using var fixture = new BoundaryRemoteFixture(); + int publications = 0; + + Assert.True(LiveEntityShadowPublisher.TryPublishRemote( + fixture.Live, + fixture.Record, + fixture.Entity, + fixture.Remote, + fixture.Record.PositionAuthorityVersion, + () => publications++)); + Assert.Equal(1, publications); + + Assert.True(fixture.Live.RebucketLiveEntity( + BoundaryRemoteFixture.Guid, + BoundaryRemoteFixture.DestinationCell)); + Assert.False(fixture.Record.IsSpatiallyVisible); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount); + + Assert.False(LiveEntityShadowPublisher.TryPublishRemote( + fixture.Live, + fixture.Record, + fixture.Entity, + fixture.Remote, + fixture.Record.PositionAuthorityVersion, + () => publications++)); + Assert.Equal(1, publications); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount); + + fixture.HydrateDestination(); + + Assert.True(fixture.Record.IsSpatiallyVisible); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount); + } + + [Fact] + public void AuthoritativeShadowPublisher_StaleAuthorityAndGuidReuseCannotPublish() + { + using var fixture = new BoundaryRemoteFixture(); + LiveEntityRecord oldRecord = fixture.Record; + WorldEntity oldEntity = fixture.Entity; + GameWindow.RemoteMotion oldRemote = fixture.Remote; + ulong oldAuthority = oldRecord.PositionAuthorityVersion; + int publications = 0; + + Assert.False(LiveEntityShadowPublisher.TryPublishRemote( + fixture.Live, + oldRecord, + oldEntity, + oldRemote, + oldAuthority + 1, + () => publications++)); + + var replacement = fixture.ReplaceAtSource(); + + Assert.False(LiveEntityShadowPublisher.TryPublishRemote( + fixture.Live, + oldRecord, + oldEntity, + oldRemote, + oldAuthority, + () => publications++)); + Assert.Equal(0, publications); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Contains( + fixture.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == replacement.Entity.Id); + Assert.DoesNotContain( + fixture.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == oldEntity.Id); + } + + [Fact] + public void Tick_CrossCellVisibilityCallbackGuidReuse_CannotPublishOldShadow() + { + using var fixture = new BoundaryRemoteFixture(); + LiveEntityRecord oldRecord = fixture.Record; + WorldEntity oldEntity = fixture.Entity; + GameWindow.RemoteMotion oldRemote = fixture.Remote; + WorldEntity? replacementEntity = null; + GameWindow.RemoteMotion? replacementRemote = null; + ulong clockEpoch = oldRecord.ObjectClockEpoch; + + fixture.Live.ProjectionVisibilityChanged += (record, visible) => + { + if (visible + || replacementEntity is not null + || !ReferenceEquals(record, oldRecord)) + { + return; + } + + (LiveEntityRecord replacementRecord, + replacementEntity, + replacementRemote) = fixture.ReplaceAtSource(); + Assert.NotSame(oldRecord, replacementRecord); + }; + + bool current = fixture.Updater.Tick( + oldRemote, + oldEntity, + objectScale: 1f, + sequencer: null, + animationForVelocityCycle: null, + dt: 2f, + rootMotionLocalFrame: new MotionDeltaFrame(), + liveCenterX: 0, + liveCenterY: 0, + ownerRuntime: fixture.Live, + ownerRecord: oldRecord, + ownerClockEpoch: clockEpoch); + + Assert.False(current); + Assert.NotNull(replacementEntity); + Assert.NotNull(replacementRemote); + Assert.True(fixture.Live.TryGetRecord( + BoundaryRemoteFixture.Guid, + out LiveEntityRecord currentRecord)); + Assert.NotSame(oldRecord, currentRecord); + Assert.Same(replacementEntity, currentRecord.WorldEntity); + Assert.Same(replacementRemote, currentRecord.RemoteMotionRuntime); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount); + Assert.Contains( + fixture.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == replacementEntity.Id); + Assert.DoesNotContain( + fixture.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == oldEntity.Id); } [Fact] @@ -192,6 +704,7 @@ public sealed class RemotePhysicsUpdaterTests (_, _) => (0.48f, 1.835f), (_, _, _, _) => { }); var published = new List(); + var partPoseDirty = new List(); updater.TickHiddenEntities( live, localPlayerServerGuid: 0x50000001u, @@ -203,9 +716,11 @@ public sealed class RemotePhysicsUpdaterTests Assert.True(live.UnregisterLiveEntity( new DeleteObject.Parsed(other, InstanceSequence: 1), isLocalPlayer: false)); - }); + }, + markPartPoseDirty: partPoseDirty.Add); Assert.Single(published); + Assert.Equal(published, partPoseDirty); Assert.Single(live.SpatialRemoteMotionRuntimes); } @@ -309,4 +824,208 @@ public sealed class RemotePhysicsUpdaterTests currentBodyPosition: entity.Position); live.SetRemoteMotionRuntime(guid, remote); } + + private sealed class BoundaryRemoteFixture : IDisposable + { + public const uint Guid = 0x70000071u; + public const uint SourceCell = 0xA9B40039u; + public const uint DestinationCell = 0xAAB40001u; + + private LiveEntityPresentationController? _presentation; + + public BoundaryRemoteFixture() + { + Engine = BuildBoundaryEngine(); + Spatial.AddLandblock(new LoadedLandblock( + 0xA9B4FFFFu, + new LandBlock(), + Array.Empty())); + Live = new LiveEntityRuntime( + Spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + record => + { + if (record.WorldEntity is { } entity) + Engine.ShadowObjects.Deregister(entity.Id); + _presentation?.Forget(record); + }); + (Record, Entity, Remote) = SpawnAndBind( + instanceSequence: 1, + new Vector3(191f, 10f, 50f), + enqueueDestination: true); + _presentation = new LiveEntityPresentationController( + Live, + Engine.ShadowObjects, + (_, _, _) => true, + liveCenter: () => (0, 0)); + RegisterShadow(Entity, Remote); + Assert.True(_presentation.OnLiveEntityReady(Guid)); + Updater = new RemotePhysicsUpdater( + Engine, + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + } + + public PhysicsEngine Engine { get; } + public GpuWorldState Spatial { get; } = new(); + public LiveEntityRuntime Live { get; } + public RemotePhysicsUpdater Updater { get; } + public LiveEntityRecord Record { get; } + public WorldEntity Entity { get; } + public GameWindow.RemoteMotion Remote { get; } + + public void HydrateDestination() => + Spatial.AddLandblock(new LoadedLandblock( + 0xAAB4FFFFu, + new LandBlock(), + Array.Empty())); + + public (LiveEntityRecord Record, WorldEntity Entity, + GameWindow.RemoteMotion Remote) ReplaceAtSource() + { + Assert.True(Live.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, InstanceSequence: 1), + isLocalPlayer: false)); + var replacement = SpawnAndBind( + instanceSequence: 2, + new Vector3(180f, 20f, 50f), + enqueueDestination: false); + RegisterShadow(replacement.Entity, replacement.Remote); + Assert.True(_presentation!.OnLiveEntityReady(Guid)); + return replacement; + } + + public void Dispose() + { + Live.Clear(); + _presentation?.Dispose(); + } + + private (LiveEntityRecord Record, WorldEntity Entity, + GameWindow.RemoteMotion Remote) SpawnAndBind( + ushort instanceSequence, + Vector3 position, + bool enqueueDestination) + { + LiveEntityRegistrationResult registration = Live.RegisterLiveEntity( + Spawn(instanceSequence, position)); + LiveEntityRecord record = Assert.IsType( + registration.Record); + WorldEntity entity = Assert.IsType( + Live.MaterializeLiveEntity( + Guid, + SourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = position, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = SourceCell, + })); + var remote = new GameWindow.RemoteMotion(); + remote.Body.Position = position; + remote.Body.Orientation = Quaternion.Identity; + remote.CellId = SourceCell; + remote.LastShadowSyncPos = position; + remote.LastShadowSyncOrientation = Quaternion.Identity; + if (enqueueDestination) + { + remote.Interp.Enqueue( + new Vector3(193f, 10f, 50f), + heading: 0f, + isMovingTo: false, + currentBodyPosition: position); + } + Live.SetRemoteMotionRuntime(Guid, remote); + return (record, entity, remote); + } + + private void RegisterShadow( + WorldEntity entity, + GameWindow.RemoteMotion remote) + { + Engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + remote.Body.Position, + remote.Body.Orientation, + radius: 0.48f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0xA9B40000u, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 1.835f, + state: (uint)PhysicsStateFlags.ReportCollisions, + seedCellId: SourceCell, + isStatic: false); + } + + private static WorldSession.EntitySpawn Spawn( + ushort instanceSequence, + Vector3 position) + { + const PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions; + var serverPosition = new CreateObject.ServerPosition( + SourceCell, + position.X, + position.Y, + position.Z, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + var physics = new PhysicsSpawnData( + RawState: (uint)state, + 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, + "boundary remote", + null, + null, + MotionTableId: 0x09000001u, + PhysicsState: (uint)state, + InstanceSequence: instanceSequence, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + } + + private static AnimationSequencer CreateSequencer() => + new(new Setup(), new MotionTable(), new NullAnimationLoader()); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } } diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs index c2c3271e..7a261071 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs @@ -44,6 +44,7 @@ public sealed class RemoteTeleportControllerTests public Vector3 LastServerPosition { get; set; } public double LastServerPositionTime { get; set; } public Vector3 LastShadowSyncPosition { get; set; } + public Quaternion LastShadowSyncOrientation { get; set; } public void BindCanonicalCell(Func read, Action write) { _readCell = read; @@ -64,6 +65,7 @@ public sealed class RemoteTeleportControllerTests public Vector3 LastServerPosition { get; set; } public double LastServerPositionTime { get; set; } public Vector3 LastShadowSyncPosition { get; set; } + public Quaternion LastShadowSyncOrientation { get; set; } public void BindCanonicalCell(Func read, Action write) { } public void HitGround() { } public void LeaveGround() { } @@ -921,6 +923,165 @@ public sealed class RemoteTeleportControllerTests Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); } + [Fact] + public void ResolverAcceptsNewerPosition_OlderPlacementReturnsSuperseded() + { + const uint guid = 0x70000060u; + const uint sourceCell = 0xA9B40039u; + const uint destinationCell = 0xAAB40001u; + var fixture = CreateLoadedRemoteFixture(guid, sourceCell); + Assert.True(fixture.Live.TryApplyPosition( + PositionUpdate(guid, destinationCell, x: 1f, sequence: 2), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + Assert.True(fixture.Live.RebucketLiveEntity(guid, destinationCell)); + Assert.True(fixture.Live.TryGetRecord(guid, out LiveEntityRecord record)); + ulong outerPositionAuthority = record.PositionAuthorityVersion; + ulong outerVelocityAuthority = record.VelocityAuthorityVersion; + Vector3 newerWorldPosition = new(195f, 10f, 50f); + RemoteTeleportController? controller = null; + RemoteTeleportController.Result nestedResult = default; + bool nested = false; + controller = new RemoteTeleportController( + fixture.Engine, + fixture.Live, + (_, _) => (0.48f, 1.835f), + CellLocal, + (_, _, _) => { }, + (_, _, _) => { }, + (_, _) => { }, + resolvePlacement: (position, cell, _, _, _, _) => + { + if (!nested) + { + nested = true; + Assert.True(fixture.Live.TryApplyPosition( + PositionUpdate(guid, destinationCell, x: 3f, sequence: 3), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + Assert.True(fixture.Live.TryGetRecord(guid, out var current)); + nestedResult = controller!.TryApply( + current, + current.PositionAuthorityVersion, + current.VelocityAuthorityVersion, + fixture.Remote, + fixture.Entity, + newerWorldPosition, + destinationCell, + new Vector3(3f, 10f, 50f), + Quaternion.Identity, + gameTime: 6.0, + destinationProjectionVisible: true, + generation: 1, + positionSequence: 3); + } + + return SuccessfulPlacement(position, cell); + }); + + RemoteTeleportController.Result outerResult = controller.TryApply( + record, + outerPositionAuthority, + outerVelocityAuthority, + fixture.Remote, + fixture.Entity, + new Vector3(193f, 10f, 50f), + destinationCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 5.0, + destinationProjectionVisible: true, + generation: 1, + positionSequence: 2); + + Assert.True(nestedResult.Applied); + Assert.True(nestedResult.ContactResolved); + Assert.True(outerResult.Superseded); + Assert.False(outerResult.Applied); + Assert.Equal(newerWorldPosition, fixture.Remote.Body.Position); + Assert.Equal(newerWorldPosition, fixture.Entity.Position); + controller.Dispose(); + } + + [Fact] + public void ResolverAcceptsIndependentState_PositionPlacementStillCommits() + { + const uint guid = 0x70000061u; + const uint sourceCell = 0xA9B40039u; + const uint destinationCell = 0xAAB40001u; + var fixture = CreateLoadedRemoteFixture(guid, sourceCell); + Assert.True(fixture.Live.TryApplyPosition( + PositionUpdate(guid, destinationCell, x: 1f, sequence: 2), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + Assert.True(fixture.Live.RebucketLiveEntity(guid, destinationCell)); + Assert.True(fixture.Live.TryGetRecord(guid, out LiveEntityRecord record)); + ulong positionAuthority = record.PositionAuthorityVersion; + ulong velocityAuthority = record.VelocityAuthorityVersion; + bool stateAccepted = false; + using var controller = new RemoteTeleportController( + fixture.Engine, + fixture.Live, + (_, _) => (0.48f, 1.835f), + CellLocal, + (_, _, _) => { }, + (_, _, _) => { }, + (_, _) => { }, + resolvePlacement: (position, cell, _, _, _, _) => + { + if (!stateAccepted) + { + stateAccepted = true; + Assert.True(fixture.Live.TryApplyState( + new SetState.Parsed( + guid, + (uint)(PhysicsStateFlags.Hidden + | PhysicsStateFlags.ReportCollisions), + 1, + 2), + out _)); + } + return SuccessfulPlacement(position, cell); + }); + Vector3 destination = new(193f, 10f, 50f); + + RemoteTeleportController.Result result = controller.TryApply( + record, + positionAuthority, + velocityAuthority, + fixture.Remote, + fixture.Entity, + destination, + destinationCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 5.0, + destinationProjectionVisible: true, + generation: 1, + positionSequence: 2); + + Assert.True(stateAccepted); + Assert.True(result.Applied); + Assert.True(result.ContactResolved); + Assert.False(result.Superseded); + Assert.Equal(destination, fixture.Remote.Body.Position); + Assert.Equal(destination, fixture.Entity.Position); + Assert.True(record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); + Assert.Equal(record.FinalPhysicsState, fixture.Remote.Body.State); + } + [Fact] public void TeardownCallbackFailure_StillForgetsPendingBeforeGuidReuse() { @@ -1009,6 +1170,75 @@ public sealed class RemoteTeleportControllerTests return engine; } + private static LoadedRemoteFixture CreateLoadedRemoteFixture( + uint guid, + uint sourceCell) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); + var live = new LiveEntityRuntime(spatial, new Resources()); + live.RegisterLiveEntity(Spawn(guid, sourceCell)); + WorldEntity entity = live.MaterializeLiveEntity( + guid, + sourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(191f, 10f, 50f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + })!; + var remote = new GameWindow.RemoteMotion(); + remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); + live.SetRemoteMotionRuntime(guid, remote); + return new LoadedRemoteFixture( + live, + entity, + remote, + BuildEngine(includeDestination: true)); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + uint cell, + float x, + ushort sequence) => new( + guid, + new CreateObject.ServerPosition( + cell, + x, + 10f, + 50f, + 1f, + 0f, + 0f, + 0f), + null, + null, + true, + 1, + sequence, + 0, + 0); + + private static ResolveResult SuccessfulPlacement(Vector3 position, uint cell) => new( + position, + cell, + IsOnGround: true, + InContact: true, + OnWalkable: true, + ContactPlane: new Plane(Vector3.UnitZ, 0f), + ContactPlaneCellId: cell); + + private readonly record struct LoadedRemoteFixture( + LiveEntityRuntime Live, + WorldEntity Entity, + GameWindow.RemoteMotion Remote, + PhysicsEngine Engine); + private static void AddDestination(PhysicsEngine engine) => engine.AddLandblock( 0xAAB4FFFFu, diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs new file mode 100644 index 00000000..fab06134 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -0,0 +1,835 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Rendering; + +public sealed class LiveEntityAnimationSchedulerTests +{ + private const uint LocalGuid = 0x50000001u; + private const uint RemoteGuid = 0x70000001u; + private const uint Cell = 0x01010001u; + + [Fact] + public void HiddenLocalPose_IsSampledOnlyForMarkedObjectQuantum() + { + var (live, record, animation) = BuildHiddenAnimated(LocalGuid); + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + localPlayerGuid: LocalGuid); + + Assert.True(scheduler.Tick( + PhysicsBody.MaxQuantum, + animation.Entity.Position, + localHiddenPartPoseDirty: true, + liveCenterX: 0, + liveCenterY: 0) + .TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule marked)); + bool repeated = scheduler.Tick( + PhysicsBody.MaxQuantum, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0) + .TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule consumed); + + Assert.True(marked.ComposeParts); + Assert.NotNull(marked.SequenceFrames); + Assert.False(repeated); + Assert.False(consumed.ComposeParts); + } + + [Fact] + public void HiddenRemotePose_IsSampledOnceAfterAdmittedHiddenQuantum() + { + var (live, record, animation) = BuildHiddenAnimated(RemoteGuid); + var remote = new GameWindow.RemoteMotion + { + CellId = Cell, + }; + remote.Body.Position = animation.Entity.Position; + remote.Body.Orientation = animation.Entity.Rotation; + remote.Body.InWorld = true; + live.SetRemoteMotionRuntime(RemoteGuid, remote); + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + localPlayerGuid: LocalGuid); + + Assert.True(scheduler.Tick( + PhysicsBody.MaxQuantum, + playerPosition: null, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0) + .TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule marked)); + bool repeated = scheduler.Tick( + elapsedSeconds: 0f, + playerPosition: null, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0) + .TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule consumed); + + Assert.True(marked.ComposeParts); + Assert.NotNull(marked.SequenceFrames); + Assert.False(repeated); + Assert.False(consumed.ComposeParts); + } + + [Fact] + public void VisibleAnimationWithoutRemoteMotion_AppliesCompleteRootFrame() + { + var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); + var retainedBodyOwner = BuildRemote(animation.Entity); + retainedBodyOwner.Body.SnapToCell( + Cell, + animation.Entity.Position, + animation.Entity.Position); + live.SetRemoteMotionRuntime(RemoteGuid, retainedBodyOwner); + Assert.True(live.ClearRemoteMotionRuntime(RemoteGuid)); + LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid); + float startX = animation.Entity.Position.X; + + Assert.True(scheduler.Tick( + 0.1f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0) + .ContainsKey(animation.Entity.Id)); + + Assert.True(animation.Entity.Position.X > startX + 1f); + } + + [Fact] + public void BodylessNonWalkableAnimation_SuppressesRootOriginButPreservesOrientation() + { + Quaternion rootTurn = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f); + var (live, _, animation) = BuildVisibleAnimatedWithPosFrames( + RemoteGuid, + rootTurn); + Vector3 startPosition = animation.Entity.Position; + LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid); + + scheduler.Tick( + PhysicsBody.MinQuantum * 1.01f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + + Assert.Equal(startPosition, animation.Entity.Position); + Assert.InRange( + MathF.Abs(Quaternion.Dot( + rootTurn, + Quaternion.Normalize(animation.Entity.Rotation))), + 0.99999f, + 1.00001f); + } + + [Fact] + public void RemoteWithoutAnimation_RemainsInOrdinaryObjectWorkset() + { + var (live, record, entity) = BuildMaterialized( + RemoteGuid, + PhysicsStateFlags.Gravity); + var remote = new GameWindow.RemoteMotion + { + CellId = Cell, + Airborne = true, + }; + remote.Body.Position = entity.Position; + remote.Body.Orientation = entity.Rotation; + remote.Body.Velocity = new Vector3(3f, 0f, 0f); + remote.Body.State = PhysicsStateFlags.Gravity; + remote.Body.TransientState = TransientStateFlags.Active; + live.SetRemoteMotionRuntime(RemoteGuid, remote); + LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid); + float startX = entity.Position.X; + + scheduler.Tick( + 0.1f, + entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + + Assert.True(entity.Position.X > startX); + Assert.Same(record, live.SpatialRootObjects[RemoteGuid]); + } + + [Fact] + public void AnimationlessRemote_NearPositionCorrectionIsQueuedAndConsumed() + { + var (live, record, entity) = BuildMaterialized( + RemoteGuid, + PhysicsStateFlags.ReportCollisions); + var remote = BuildRemote(entity); + remote.Interp.Enqueue( + entity.Position + Vector3.UnitX, + heading: 0f, + isMovingTo: false, + currentBodyPosition: remote.Body.Position); + live.SetRemoteMotionRuntime(RemoteGuid, remote); + Assert.Null(record.AnimationRuntime); + Assert.True(live.IsCurrentSpatialRemoteMotion(record, remote)); + LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid); + float startX = entity.Position.X; + + scheduler.Tick( + 0.1f, + entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 1, + liveCenterY: 1); + + Assert.True(entity.Position.X > startX + 0.5f); + Assert.True(remote.Interp.IsActive || entity.Position.X >= startX + 0.99f); + Assert.Same(record, live.SpatialRootObjects[RemoteGuid]); + } + + [Fact] + public void RetainedProjectileWithoutRemote_WhenMissileClears_AdvancesOnceAsOrdinaryBody() + { + var (live, record, animation) = BuildVisibleAnimatedWithPosFrames( + RemoteGuid, + rootOrigin: Vector3.Zero); + var physics = new PhysicsEngine(); + var projectiles = new ProjectileController(live, physics); + record.FinalPhysicsState = ProjectileState; + Assert.True(record.ObjectClock.IsActive); + Assert.True(projectiles.TryBind( + record, + ProjectileSetup(), + currentTime: 1.0, + liveCenterX: 1, + liveCenterY: 1)); + Assert.True(record.ObjectClock.IsActive); + PhysicsBody body = record.PhysicsBody!; + body.set_velocity(new Vector3(3f, 0f, 0f)); + body.TransientState = TransientStateFlags.Active; + + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Gravity; + Assert.True(projectiles.ApplyAuthoritativeState( + record, + record.FinalPhysicsState, + currentTime: 1.1, + liveCenterX: 1, + liveCenterY: 1)); + Assert.True(projectiles.HandlesMovement(RemoteGuid)); + float startX = animation.Entity.Position.X; + int rootPublishes = 0; + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + publishRootPose: _ => rootPublishes++, + physics: physics, + projectiles: projectiles); + + scheduler.Tick( + 0.1f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 1, + liveCenterY: 1); + + float distance = animation.Entity.Position.X - startX; + Assert.InRange(distance, 0.2f, 0.4f); + Assert.Equal(1, rootPublishes); + Assert.Same(body, record.PhysicsBody); + Assert.Same(body, record.ProjectileRuntime!.Body); + } + + [Fact] + public void RetainedProjectileWithRemote_WhenMissileClears_TransfersMovementToRemoteOnce() + { + var (live, record, animation) = BuildVisibleAnimatedWithPosFrames( + RemoteGuid, + rootOrigin: Vector3.Zero); + var physics = new PhysicsEngine(); + var projectiles = new ProjectileController(live, physics); + record.FinalPhysicsState = ProjectileState; + var remote = new GameWindow.RemoteMotion + { + CellId = Cell, + Airborne = false, + }; + remote.Body.Position = animation.Entity.Position; + remote.Body.Orientation = animation.Entity.Rotation; + remote.Body.State = ProjectileState; + remote.Body.InWorld = true; + remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable; + live.SetRemoteMotionRuntime(RemoteGuid, remote); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal(Cell, record.FullCellId); + Assert.True(record.IsSpatiallyVisible); + Assert.True(projectiles.TryBind( + record, + ProjectileSetup(), + currentTime: 1.0, + liveCenterX: 1, + liveCenterY: 1)); + Assert.Equal(Cell, record.FullCellId); + Assert.True(record.IsSpatiallyVisible); + Assert.True(record.ObjectClock.IsActive); + Assert.Same(remote.Body, record.ProjectileRuntime!.Body); + + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Gravity; + Assert.True(projectiles.ApplyAuthoritativeState( + record, + record.FinalPhysicsState, + currentTime: 1.1, + liveCenterX: 1, + liveCenterY: 1)); + Assert.False(projectiles.HandlesMovement(RemoteGuid)); + remote.Interp.Enqueue( + animation.Entity.Position + Vector3.UnitX, + heading: 0f, + isMovingTo: false, + currentBodyPosition: remote.Body.Position); + Assert.True(record.ObjectClock.IsActive); + Assert.True(remote.Body.IsActive); + Assert.True(live.IsCurrentSpatialRemoteMotion(record, remote)); + Assert.True(live.IsCurrentSpatialAnimation(record, animation)); + float startX = animation.Entity.Position.X; + int rootPublishes = 0; + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + publishRootPose: _ => rootPublishes++, + physics: physics, + projectiles: projectiles); + + IReadOnlyDictionary schedules = scheduler.Tick( + 0.1f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 1, + liveCenterY: 1); + + Assert.Contains(animation.Entity.Id, schedules.Keys); + Assert.Equal(1, rootPublishes); + float distance = animation.Entity.Position.X - startX; + Assert.InRange(distance, 0.79f, 0.81f); + Assert.Same(remote.Body, record.PhysicsBody); + Assert.Same(remote.Body, record.ProjectileRuntime!.Body); + } + + [Fact] + public void DeleteFromFirstQuantumCallback_StopsCatchUpBatchAndPosePublish() + { + var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); + int captures = 0; + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + (_, _) => + { + captures++; + live.UnregisterLiveEntity( + new DeleteObject.Parsed(RemoteGuid, record.Generation), + isLocalPlayer: false); + }); + + IReadOnlyDictionary schedules = scheduler.Tick( + PhysicsBody.MaxQuantum * 2.5f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + + Assert.Equal(1, captures); + Assert.Empty(schedules); + Assert.False(live.TryGetRecord(RemoteGuid, out _)); + } + + [Fact] + public void VisibleRemoteDeleteInsideHook_StopsBeforeStaleEntityPublication() + { + var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); + var remote = BuildRemote(animation.Entity); + live.SetRemoteMotionRuntime(RemoteGuid, remote); + Vector3 retainedEntityPosition = animation.Entity.Position; + int captures = 0; + int rootPublishes = 0; + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + (_, _) => + { + captures++; + live.UnregisterLiveEntity( + new DeleteObject.Parsed(RemoteGuid, record.Generation), + isLocalPlayer: false); + }, + _ => rootPublishes++); + + IReadOnlyDictionary schedules = scheduler.Tick( + PhysicsBody.MaxQuantum * 2.5f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + + Assert.Equal(1, captures); + Assert.Equal(0, rootPublishes); + Assert.Empty(schedules); + Assert.Equal(retainedEntityPosition, animation.Entity.Position); + } + + [Fact] + public void HiddenRemoteDeleteInsideHook_StopsBeforeStaleEntityPublication() + { + var (live, record, animation) = BuildHiddenAnimated(RemoteGuid); + var remote = BuildRemote(animation.Entity); + live.SetRemoteMotionRuntime(RemoteGuid, remote); + Vector3 retainedEntityPosition = animation.Entity.Position; + int captures = 0; + int rootPublishes = 0; + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + (_, _) => + { + captures++; + live.UnregisterLiveEntity( + new DeleteObject.Parsed(RemoteGuid, record.Generation), + isLocalPlayer: false); + }, + _ => rootPublishes++); + + IReadOnlyDictionary schedules = scheduler.Tick( + PhysicsBody.MaxQuantum * 2.5f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + + Assert.Equal(1, captures); + Assert.Equal(0, rootPublishes); + Assert.Empty(schedules); + Assert.Equal(retainedEntityPosition, animation.Entity.Position); + } + + [Fact] + public void HiddenAnimationWithoutRemoteDeleteInsideHook_StopsBeforeManagerTailOrPublication() + { + var (live, record, animation) = BuildHiddenAnimated(RemoteGuid); + int captures = 0; + int rootPublishes = 0; + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + (_, _) => + { + captures++; + live.UnregisterLiveEntity( + new DeleteObject.Parsed(RemoteGuid, record.Generation), + isLocalPlayer: false); + }, + _ => rootPublishes++); + + IReadOnlyDictionary schedules = scheduler.Tick( + PhysicsBody.MaxQuantum * 2.5f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + + Assert.Equal(1, captures); + Assert.Equal(0, rootPublishes); + Assert.Empty(schedules); + Assert.False(live.TryGetRecord(RemoteGuid, out _)); + } + + [Fact] + public void WithdrawAndReenterInsideHook_InvalidatesOldCatchUpBatch() + { + var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); + ulong startingEpoch = record.ObjectClockEpoch; + int captures = 0; + int rootPublishes = 0; + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + (_, _) => + { + captures++; + Assert.True(live.WithdrawLiveEntityProjection(RemoteGuid)); + Assert.Same( + animation.Entity, + live.MaterializeLiveEntity( + RemoteGuid, + Cell, + _ => throw new InvalidOperationException( + "A retained projection must not be reconstructed."))); + }, + _ => rootPublishes++); + + IReadOnlyDictionary schedules = scheduler.Tick( + PhysicsBody.MaxQuantum * 2.5f, + animation.Entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + + Assert.Equal(1, captures); + Assert.Equal(0, rootPublishes); + Assert.Empty(schedules); + Assert.Equal(startingEpoch + 2, record.ObjectClockEpoch); + Assert.True(record.ObjectClock.IsActive); + Assert.Equal(0d, record.ObjectClock.PendingSeconds); + } + + [Fact] + public void LocalRootPosePublishesWithoutAnimationAndBetweenObjectQuanta() + { + var (live, _, entity) = BuildMaterialized(LocalGuid, PhysicsStateFlags.None); + var published = new List(); + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + publishRootPose: current => published.Add(current.Position)); + + scheduler.Tick( + PhysicsBody.MinQuantum * 0.25f, + entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + entity.SetPosition(entity.Position + new Vector3(0.25f, 0f, 0f)); + scheduler.Tick( + PhysicsBody.MinQuantum * 0.25f, + entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + + Assert.Equal(2, published.Count); + Assert.Equal(entity.Position, published[1]); + } + + [Fact] + public void AttachedProjection_IsNotAnIndependentOrdinaryRoot() + { + var (live, record, entity) = BuildMaterialized( + RemoteGuid, + PhysicsStateFlags.None); + int rootPublishes = 0; + LiveEntityAnimationScheduler scheduler = BuildScheduler( + live, + LocalGuid, + publishRootPose: _ => rootPublishes++); + + Assert.Same( + entity, + live.MaterializeLiveEntity( + RemoteGuid, + Cell, + _ => throw new InvalidOperationException(), + LiveEntityProjectionKind.Attached)); + Assert.False(record.ObjectClock.IsActive); + Assert.DoesNotContain(RemoteGuid, live.SpatialRootObjects.Keys); + + scheduler.Tick( + PhysicsBody.MaxQuantum, + entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + Assert.Equal(0, rootPublishes); + + Assert.Same( + entity, + live.MaterializeLiveEntity( + RemoteGuid, + Cell, + _ => throw new InvalidOperationException(), + LiveEntityProjectionKind.World)); + Assert.True(record.ObjectClock.IsActive); + Assert.Contains(RemoteGuid, live.SpatialRootObjects.Keys); + + scheduler.Tick( + PhysicsBody.MaxQuantum, + entity.Position, + localHiddenPartPoseDirty: false, + liveCenterX: 0, + liveCenterY: 0); + Assert.Equal(1, rootPublishes); + } + + private static GameWindow.RemoteMotion BuildRemote(WorldEntity entity) + { + var remote = new GameWindow.RemoteMotion + { + CellId = Cell, + }; + remote.Body.Position = entity.Position; + remote.Body.Orientation = entity.Rotation; + remote.Body.InWorld = true; + remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable; + return remote; + } + + private static LiveEntityAnimationScheduler BuildScheduler( + LiveEntityRuntime live, + uint localPlayerGuid, + Action? captureHooks = null, + Action? publishRootPose = null, + PhysicsEngine? physics = null, + ProjectileController? projectiles = null) + { + physics ??= new PhysicsEngine(); + var remotePhysics = new RemotePhysicsUpdater( + physics, + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + var ordinaryPhysics = new LiveEntityOrdinaryPhysicsUpdater( + physics, + (_, _) => (0.48f, 1.835f)); + return new LiveEntityAnimationScheduler( + () => live, + () => localPlayerGuid, + remotePhysics, + ordinaryPhysics, + () => projectiles, + publishRootPose ?? (_ => { }), + captureHooks ?? ((_, _) => { })); + } + + private static (LiveEntityRuntime Live, LiveEntityRecord Record, + GameWindow.AnimatedEntity Animation) BuildHiddenAnimated(uint guid) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var live = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!; + WorldEntity entity = live.MaterializeLiveEntity( + guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 10f, 5f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + })!; + var setup = new Setup(); + var animation = new GameWindow.AnimatedEntity + { + Entity = entity, + Setup = setup, + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = Array.Empty(), + PartAvailability = Array.Empty(), + Sequencer = new AnimationSequencer( + setup, + new MotionTable(), + new NullAnimationLoader()), + }; + record.HasPartArray = true; + live.SetAnimationRuntime(guid, animation); + return (live, record, animation); + } + + private static (LiveEntityRuntime Live, LiveEntityRecord Record, + GameWindow.AnimatedEntity Animation) BuildVisibleAnimatedWithPosFrames( + uint guid, + Quaternion? rootOrientation = null, + Vector3? rootOrigin = null) + { + var (live, record, entity) = BuildMaterialized(guid, PhysicsStateFlags.None); + const uint animationId = 0x0300AA01u; + var setup = new Setup(); + setup.Parts.Add(0x0100AA01u); + setup.DefaultScale.Add(Vector3.One); + var datAnimation = new Animation + { + Flags = AnimationFlags.PosFrames, + }; + for (int i = 0; i < 4; i++) + { + var partFrame = new AnimationFrame(1); + partFrame.Frames.Add(new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }); + datAnimation.PartFrames.Add(partFrame); + datAnimation.PosFrames.Add(new Frame + { + Origin = rootOrigin ?? Vector3.UnitX, + Orientation = rootOrientation ?? Quaternion.Identity, + }); + } + var loader = new DictionaryAnimationLoader(animationId, datAnimation); + var sequencer = new AnimationSequencer(setup, new MotionTable(), loader); + Assert.True(sequencer.InitializeSetupDefaultAnimation(animationId)); + var animation = new GameWindow.AnimatedEntity + { + Entity = entity, + Setup = setup, + Animation = datAnimation, + LowFrame = 0, + HighFrame = 3, + Framerate = 30f, + Scale = 1f, + PartTemplate = new[] + { + new GameWindow.AnimatedPartTemplate( + 0x0100AA01u, + SurfaceOverrides: null, + IsDrawable: true), + }, + PartAvailability = new[] { true }, + Sequencer = sequencer, + }; + record.HasPartArray = true; + live.SetAnimationRuntime(guid, animation); + return (live, record, animation); + } + + private static readonly PhysicsStateFlags ProjectileState = + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + + private static Setup ProjectileSetup() => new() + { + Spheres = + { + new Sphere + { + Origin = Vector3.Zero, + Radius = 0.1f, + }, + }, + }; + + private static (LiveEntityRuntime Live, LiveEntityRecord Record, WorldEntity Entity) + BuildMaterialized(uint guid, PhysicsStateFlags state) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var live = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid, state)).Record!; + WorldEntity entity = live.MaterializeLiveEntity( + guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 10f, 5f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + })!; + return (live, record, entity); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + PhysicsStateFlags state = PhysicsStateFlags.Hidden + | PhysicsStateFlags.IgnoreCollisions) + { + var position = new CreateObject.ServerPosition( + Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + var physics = new PhysicsSpawnData( + RawState: (uint)state, + Position: position, + 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, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "hidden scheduler fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)state, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } + + private sealed class DictionaryAnimationLoader : IAnimationLoader + { + private readonly uint _id; + private readonly Animation _animation; + + public DictionaryAnimationLoader(uint id, Animation animation) + { + _id = id; + _animation = animation; + } + + public Animation? LoadAnimation(uint id) => id == _id ? _animation : null; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs new file mode 100644 index 00000000..03b6fd83 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -0,0 +1,542 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RetailStaticAnimatingObjectSchedulerTests +{ + private const uint OwnerId = 0x7F000001u; + private const uint AnimationId = 0x0300AA01u; + private const uint GfxId = 0x0100AA01u; + + [Fact] + public void DefaultAnimation_UsesSeparateWholeElapsedStaticWorkset() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + int hookCaptures = 0; + int posePublishes = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => hookCaptures++, + (_, _, _) => posePublishes++); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + + var animatedIds = new HashSet(); + scheduler.CopyAnimatedEntityIdsTo(animatedIds); + Assert.Contains(OwnerId, animatedIds); + + scheduler.Tick(1f / 60f); + scheduler.ProcessHooks(); + + Assert.Equal(1, hookCaptures); + Assert.Equal(1, posePublishes); + Assert.Single(entity.MeshRefs); + Assert.True(entity.MeshRefs[0].PartTransform.Translation.X > 0f); + + scheduler.Tick(2f); + scheduler.ProcessHooks(); + Assert.Equal(2, hookCaptures); + Assert.Equal(2, posePublishes); + + Matrix4x4 beforeDiscard = entity.MeshRefs[0].PartTransform; + scheduler.Tick(2.01f); + scheduler.ProcessHooks(); + Assert.Equal(beforeDiscard, entity.MeshRefs[0].PartTransform); + Assert.Equal(2, hookCaptures); + Assert.Equal(2, posePublishes); + } + + [Fact] + public void SubEpsilonElapsed_IsDiscardedInsteadOfAccumulated() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + int hookCaptures = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => hookCaptures++, + (_, _, _) => { }); + WorldEntity entity = MakeEntity(); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: MakeSetup(), + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + + scheduler.Tick(0.0001f); + scheduler.ProcessHooks(); + scheduler.Tick(0.0001f); + scheduler.ProcessHooks(); + Assert.Equal(0, hookCaptures); + + scheduler.Tick(0.0003f); + scheduler.ProcessHooks(); + Assert.Equal(1, hookCaptures); + } + + [Fact] + public void ScriptOnlyOwner_DoesNotEnterAnimationWorkset() + { + var scheduler = new RetailStaticAnimatingObjectScheduler( + new Loader(), + (_, _) => { }, + (_, _, _) => { }); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0x3300AA01u, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup)); + + Assert.Equal(0, scheduler.Count); + var animatedIds = new HashSet(); + scheduler.CopyAnimatedEntityIdsTo(animatedIds); + Assert.Empty(animatedIds); + + scheduler.Unregister(OwnerId); + } + + [Fact] + public void LivePhysicsStaticOwner_CatchesUpShortNonResidentIntervalOnReentry() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + bool resident = false; + int captures = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => captures++, + (_, _, _) => { }, + _ => resident); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + BindLiveOwner( + scheduler, + entity, + setup, + loader, + out _); + + scheduler.Tick(0.02f); + scheduler.ProcessHooks(); + Assert.Equal(0, captures); + + resident = true; + scheduler.Tick(0.02f); + scheduler.ProcessHooks(); + Assert.Equal(1, captures); + Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames)); + + var reference = new AnimationSequencer( + setup, + new MotionTable(), + loader); + IReadOnlyList referenceFrames = reference.Advance(0.04f); + + Assert.Equal(referenceFrames[0], frames[0]); + } + + [Fact] + public void LivePhysicsStaticOwner_DiscardsLongNonResidentIntervalOnReentry() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + bool resident = false; + int captures = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => captures++, + (_, _, _) => { }, + _ => resident); + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: MakeSetup(), + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + BindLiveOwner( + scheduler, + entity, + MakeSetup(), + loader, + out _); + + scheduler.Tick(2.01f); + scheduler.ProcessHooks(); + resident = true; + scheduler.Tick(0.1f); + scheduler.ProcessHooks(); + Assert.Equal(0, captures); + + scheduler.Tick(0.1f); + scheduler.ProcessHooks(); + Assert.Equal(1, captures); + } + + [Fact] + public void LivePhysicsStaticOwner_UsesCanonicalSequencerAndRawOmega() + { + const uint alternateAnimationId = 0x0300AA02u; + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + loader.Add(alternateAnimationId, OffsetAnimation(20f)); + var shadows = new ShadowObjectRegistry(); + Matrix4x4 committedRoot = Matrix4x4.Identity; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => { }, + (_, _, _) => { }, + commitLiveRoot: (entity, body) => + { + shadows.UpdatePosition( + entity.Id, + body.Position, + body.Orientation, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + seedCellId: body.CellPosition.ObjCellId); + committedRoot = Matrix4x4.CreateFromQuaternion(entity.Rotation) + * Matrix4x4.CreateTranslation(entity.Position); + }); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + entity.Position = new Vector3(12f, 12f, 50f); + shadows.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + new[] + { + new ShadowShape( + GfxId, + Vector3.UnitX, + Quaternion.Identity, + Scale: 1f, + CollisionType: ShadowCollisionType.Cylinder, + Radius: 0.5f, + CylHeight: 1f), + }, + state: 0u, + flags: EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + seedCellId: 0x01010001u); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + AnimationSequencer sequencer = BindLiveOwner( + scheduler, + entity, + setup, + loader, + out PhysicsBody body); + body.Omega = new Vector3(0f, 0f, MathF.PI / 2f); + + // Simulates UpdateMotion replacing the sequence on the same PartArray. + Assert.True(sequencer.InitializeSetupDefaultAnimation(alternateAnimationId)); + scheduler.Tick(0.01f); + + Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames)); + Assert.True(frames[0].Origin.X >= 20f); + Vector3 rotatedX = Vector3.Transform(Vector3.UnitX, entity.Rotation); + Assert.InRange(rotatedX.X, -0.001f, 0.001f); + Assert.InRange(rotatedX.Y, 0.999f, 1.001f); + Assert.Equal(entity.Rotation, body.Orientation); + ShadowEntry rotatedShadow = Assert.Single( + shadows.AllEntriesForDebug(), + item => item.EntityId == entity.Id); + Assert.InRange(rotatedShadow.Position.X, 11.999f, 12.001f); + Assert.InRange(rotatedShadow.Position.Y, 12.999f, 13.001f); + Vector3 committedX = Vector3.TransformNormal( + Vector3.UnitX, + committedRoot); + Assert.InRange(committedX.X, -0.001f, 0.001f); + Assert.InRange(committedX.Y, 0.999f, 1.001f); + } + + [Fact] + public void LivePhysicsStaticOwner_ZeroOmegaDoesNotCommitUnchangedRoot() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + int rootCommits = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => { }, + (_, _, _) => { }, + commitLiveRoot: (_, _) => rootCommits++); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + BindLiveOwner(scheduler, entity, setup, loader, out _); + + scheduler.Tick(0.1f); + + Assert.Equal(0, rootCommits); + Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + } + + [Fact] + public void LivePhysicsStaticOwner_WithdrawalDuringRootCommitDropsPreparedPoseAndHooks() + { + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + bool resident = true; + ulong residencyVersion = 1; + int captures = 0; + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (_, _) => captures++, + (_, _, _) => { }, + isResident: _ => resident, + commitLiveRoot: (_, _) => + { + resident = false; + residencyVersion++; + }, + residencyVersion: _ => residencyVersion); + Setup setup = MakeSetup(); + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + BindLiveOwner(scheduler, entity, setup, loader, out PhysicsBody body); + body.Omega = new Vector3(0f, 0f, MathF.PI / 2f); + + scheduler.Tick(0.1f); + scheduler.ProcessHooks(); + + Assert.False(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + Assert.Equal(0, captures); + } + + [Fact] + public void LivePhysicsStaticOwner_NonCyclicAnimationDoneRunsAfterFinalPartAndChildPose() + { + const uint style = 0x8000003Eu; + const uint readyMotion = 0x41000003u; + const uint actionMotion = 0x10000058u; + const uint actionAnimationId = 0x0300AA03u; + var loader = new Loader(); + loader.Add(AnimationId, TwoFrameAnimation()); + loader.Add(actionAnimationId, OffsetAnimation(20f)); + Setup setup = MakeSetup(); + var table = new MotionTable + { + DefaultStyle = (DRWMotionCommand)style, + }; + table.StyleDefaults[(DRWMotionCommand)style] = + (DRWMotionCommand)readyMotion; + int readyKey = unchecked((int)((style << 16) | (readyMotion & 0xFFFFFFu))); + table.Cycles[readyKey] = MakeMotionData(AnimationId); + var links = new MotionCommandData(); + links.MotionData[unchecked((int)actionMotion)] = + MakeMotionData(actionAnimationId); + table.Links[readyKey] = links; + + WorldEntity entity = MakeEntity(serverGuid: 0x70000001u); + var sequencer = new AnimationSequencer(setup, table, loader); + sequencer.SetCycle(style, readyMotion); + sequencer.ConsumePendingHooks(); + sequencer.PlayAction(actionMotion); + Assert.NotEmpty(sequencer.Manager.PendingAnimations); + + var order = new List(); + var poses = new EntityEffectPoseRegistry(); + poses.Publish(entity, entity.IndexedPartTransforms, entity.IndexedPartAvailable); + var hookFrames = new AnimationHookFrameQueue( + new AnimationHookRouter(), + poses); + var scheduler = new RetailStaticAnimatingObjectScheduler( + loader, + (ownerId, ownerSequencer) => + { + order.Add("process_hooks"); + hookFrames.Capture(ownerId, ownerSequencer); + }, + (_, _, _) => { }, + commitLiveRoot: (_, _) => order.Add("root_committed")); + scheduler.Register(entity, new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: entity.IndexedPartTransforms, + PartAvailability: entity.IndexedPartAvailable, + Setup: setup, + DefaultAnimationId: AnimationId, + UsesStaticAnimationWorkset: true)); + var body = new PhysicsBody + { + Orientation = entity.Rotation, + Omega = new Vector3(0f, 0f, MathF.PI / 2f), + }; + body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero); + Assert.True(scheduler.BindLiveOwner(entity, sequencer, body)); + sequencer.MotionDoneTarget = (_, success) => + { + Assert.True(success); + order.Add("animation_done"); + }; + + scheduler.Tick(0.2f); + + Assert.DoesNotContain("animation_done", order); + Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + order.Add("parts_published"); + order.Add("children_updated"); + scheduler.ProcessHooks(); + + Assert.Equal("root_committed", order[0]); + Assert.Equal("parts_published", order[1]); + Assert.Equal("children_updated", order[2]); + Assert.Equal("process_hooks", order[3]); + Assert.True(order.Count > 4); + Assert.All(order.Skip(4), item => Assert.Equal("animation_done", item)); + Assert.Empty(sequencer.Manager.PendingAnimations); + } + + private static Setup MakeSetup() + { + var setup = new Setup(); + setup.Parts.Add(GfxId); + setup.DefaultScale.Add(Vector3.One); + setup.DefaultAnimation = (QualifiedDataId)AnimationId; + return setup; + } + + private static WorldEntity MakeEntity( + uint serverGuid = 0, + uint ownerId = OwnerId) + { + var entity = new WorldEntity + { + Id = ownerId, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x0200AA01u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = new[] { new MeshRef(GfxId, Matrix4x4.Identity) }, + }; + entity.SetIndexedPartPoses( + new[] { Matrix4x4.Identity }, + new[] { true }); + return entity; + } + + private static Animation TwoFrameAnimation() + { + var animation = new Animation(); + var first = new AnimationFrame(1); + first.Frames.Add(new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }); + var second = new AnimationFrame(1); + second.Frames.Add(new Frame + { + Origin = new Vector3(2f, 0f, 0f), + Orientation = Quaternion.Identity, + }); + animation.PartFrames.Add(first); + animation.PartFrames.Add(second); + return animation; + } + + private static Animation OffsetAnimation(float x) + { + var animation = new Animation(); + var frame = new AnimationFrame(1); + frame.Frames.Add(new Frame + { + Origin = new Vector3(x, 0f, 0f), + Orientation = Quaternion.Identity, + }); + animation.PartFrames.Add(frame); + return animation; + } + + private static MotionData MakeMotionData(uint animationId) + { + var data = new MotionData(); + data.Anims.Add(new AnimData + { + AnimId = (QualifiedDataId)animationId, + LowFrame = 0, + HighFrame = -1, + Framerate = 10f, + }); + return data; + } + + private static AnimationSequencer BindLiveOwner( + RetailStaticAnimatingObjectScheduler scheduler, + WorldEntity entity, + Setup setup, + IAnimationLoader loader, + out PhysicsBody body) + { + var sequencer = new AnimationSequencer( + setup, + new MotionTable(), + loader); + body = new PhysicsBody + { + Orientation = entity.Rotation, + }; + body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero); + Assert.True(scheduler.BindLiveOwner(entity, sequencer, body)); + return sequencer; + } + + private sealed class Loader : IAnimationLoader + { + private readonly Dictionary _animations = new(); + public void Add(uint id, Animation animation) => _animations[id] = animation; + public Animation? LoadAnimation(uint id) => + _animations.TryGetValue(id, out Animation? animation) + ? animation + : null; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs index 7c7e7990..65bad16c 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs @@ -119,6 +119,143 @@ public sealed class EntityEffectPoseRegistryTests Assert.Equal(0, queue.Count); } + [Fact] + public void AnimationHookQueue_DeleteAndLocalIdReuse_DropsOldIncarnationHooks() + { + var poses = new EntityEffectPoseRegistry(); + WorldEntity first = Entity(9u, Vector3.Zero); + poses.Publish(first, Array.Empty()); + var router = new AnimationHookRouter(); + var sink = new RecordingSink(); + router.Register(sink); + var queue = new AnimationHookFrameQueue(router, poses); + var sequencer = new AnimationSequencer( + new Setup(), + new MotionTable(), + new NullAnimationLoader()); + + queue.Capture(9u, sequencer, new AnimationHook[] { new SoundHook() }); + Assert.True(poses.Remove(9u)); + WorldEntity replacement = Entity(9u, new Vector3(40f, 50f, 60f)); + poses.Publish(replacement, Array.Empty()); + + queue.Drain(); + + Assert.Empty(sink.Calls); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void AnimationDoneReuseDuringCapture_DropsOldIncarnationHooks() + { + var poses = new EntityEffectPoseRegistry(); + poses.Publish(Entity(9u, Vector3.Zero), Array.Empty()); + var router = new AnimationHookRouter(); + var sink = new RecordingSink(); + router.Register(sink); + var queue = new AnimationHookFrameQueue(router, poses); + var sequencer = new AnimationSequencer( + new Setup(), + new MotionTable(), + new NullAnimationLoader()); + sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u); + sequencer.MotionDoneTarget = (_, success) => + { + Assert.True(success); + Assert.True(poses.Remove(9u)); + poses.Publish( + Entity(9u, new Vector3(40f, 50f, 60f)), + Array.Empty()); + }; + + queue.Capture( + 9u, + sequencer, + new AnimationHook[] + { + new AnimationDoneHook(), + new SoundHook(), + }); + queue.Drain(); + + Assert.Empty(sink.Calls); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void MultipleAnimationDoneHooks_StopAdvancingDisplacedSequencerAfterReuse() + { + var poses = new EntityEffectPoseRegistry(); + poses.Publish(Entity(9u, Vector3.Zero), Array.Empty()); + var queue = new AnimationHookFrameQueue( + new AnimationHookRouter(), + poses); + var sequencer = new AnimationSequencer( + new Setup(), + new MotionTable(), + new NullAnimationLoader()); + sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u); + sequencer.Manager.AddToQueue(0x10000002u, ticks: 1u); + int completions = 0; + sequencer.MotionDoneTarget = (_, success) => + { + Assert.True(success); + completions++; + Assert.True(poses.Remove(9u)); + poses.Publish( + Entity(9u, new Vector3(40f, 50f, 60f)), + Array.Empty()); + }; + + queue.Capture( + 9u, + sequencer, + new AnimationHook[] + { + new AnimationDoneHook(), + new AnimationDoneHook(), + }); + + Assert.Equal(1, completions); + Assert.Single(sequencer.Manager.PendingAnimations); + queue.Drain(); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void HookSinkReuseDuringDrain_StopsRemainingOldIncarnationHooks() + { + var poses = new EntityEffectPoseRegistry(); + poses.Publish(Entity(9u, Vector3.Zero), Array.Empty()); + var router = new AnimationHookRouter(); + bool replaced = false; + var sink = new CallbackSink(() => + { + if (replaced) + return; + replaced = true; + Assert.True(poses.Remove(9u)); + poses.Publish( + Entity(9u, new Vector3(40f, 50f, 60f)), + Array.Empty()); + }); + router.Register(sink); + var queue = new AnimationHookFrameQueue(router, poses); + var sequencer = new AnimationSequencer( + new Setup(), + new MotionTable(), + new NullAnimationLoader()); + + queue.Capture( + 9u, + sequencer, + new AnimationHook[] { new SoundHook(), new SoundHook() }); + queue.Drain(); + + Assert.Equal(1, sink.Calls); + Assert.Equal(0, queue.Count); + } + [Fact] public void AnimationHookQueue_CompletesMotionStateAtCaptureEvenWhenPoseWasRemoved() { @@ -217,6 +354,17 @@ public sealed class EntityEffectPoseRegistryTests Calls.Add((entityId, entityWorldPosition)); } + private sealed class CallbackSink(Action callback) : IAnimationHookSink + { + public int Calls { get; private set; } + + public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) + { + Calls++; + callback(); + } + } + private sealed class NullAnimationLoader : IAnimationLoader { public Animation? LoadAnimation(uint animationId) => null; diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs index 45f0981d..0965dd1b 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs @@ -260,6 +260,193 @@ public sealed class EntityScriptActivatorTests Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos); } + [Fact] + public void DatStaticAnimationOwner_RegistersAndUnregistersExactlyOnce() + { + var p = BuildPipeline(); + int registerCount = 0; + int unregisterCount = 0; + WorldEntity? registeredEntity = null; + ScriptActivationInfo info = new( + ScriptId: 0, + PartTransforms: Array.Empty(), + DefaultAnimationId: 0x03000001u, + UsesStaticAnimationWorkset: true); + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => info, + registerDatStaticAnimationOwner: (entity, actual) => + { + registerCount++; + registeredEntity = entity; + Assert.Same(info, actual); + }, + unregisterDatStaticAnimationOwner: ownerId => + { + unregisterCount++; + Assert.Equal(0x40A9B420u, ownerId); + }); + WorldEntity entity = MakeDatStatic(0x40A9B420u, Vector3.One); + + activator.OnCreate(entity); + activator.OnCreate(entity); + activator.OnRemove(entity); + activator.OnRemove(entity); + + Assert.Equal(1, registerCount); + Assert.Equal(1, unregisterCount); + Assert.Same(entity, registeredEntity); + } + + [Fact] + public void LiveNonStaticEntity_DoesNotEnterStaticAnimationWorkset() + { + var p = BuildPipeline(); + int registerCount = 0; + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: Array.Empty(), + DefaultAnimationId: 0x03000001u), + registerDatStaticAnimationOwner: (_, _) => registerCount++); + + activator.OnCreate(MakeEntity(0x70000420u, Vector3.Zero)); + + Assert.Equal(0, registerCount); + } + + [Fact] + public void LivePhysicsStaticEntity_EntersAndLeavesStaticAnimationWorkset() + { + var p = BuildPipeline(); + int registerCount = 0; + int unregisterCount = 0; + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => new ScriptActivationInfo( + ScriptId: 0, + PartTransforms: Array.Empty(), + DefaultAnimationId: 0x03000001u, + UsesStaticAnimationWorkset: true), + registerDatStaticAnimationOwner: (_, _) => registerCount++, + unregisterDatStaticAnimationOwner: _ => unregisterCount++); + WorldEntity entity = MakeEntity(0x70000421u, Vector3.Zero); + + activator.OnCreate(entity); + activator.OnRemove(entity); + + Assert.Equal(1, registerCount); + Assert.Equal(1, unregisterCount); + } + + [Fact] + public void DatStaticActivation_RetriesOnlyIncompleteAcquisitionStages() + { + var p = BuildPipeline( + (0xAAu, BuildScript((10.0, new CreateParticleHook { EmitterInfoId = 100 })))); + WorldEntity entity = MakeDatStatic(0x40A9B421u, Vector3.Zero); + var setup = new DatReaderWriter.DBObjs.Setup(); + ScriptActivationInfo info = new( + ScriptId: 0xAAu, + PartTransforms: Array.Empty(), + EffectProfile: EntityEffectProfile.CreateDatStatic(setup), + Setup: setup, + DefaultAnimationId: 0x03000001u, + UsesStaticAnimationWorkset: true); + int effectAttempts = 0; + int animationAttempts = 0; + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => info, + registerDatStaticEffectOwner: (_, _, _) => + { + effectAttempts++; + if (effectAttempts == 1) + throw new InvalidOperationException("effect acquire fault"); + }, + registerDatStaticAnimationOwner: (_, _) => + { + animationAttempts++; + if (animationAttempts == 1) + throw new InvalidOperationException("animation acquire fault"); + }); + + Assert.Throws(() => activator.OnCreate(entity)); + Assert.Equal(1, effectAttempts); + Assert.Equal(0, animationAttempts); + Assert.Equal(0, p.Runner.ActiveOwnerCount); + + Assert.Throws(() => activator.OnCreate(entity)); + Assert.Equal(2, effectAttempts); + Assert.Equal(1, animationAttempts); + Assert.Equal(0, p.Runner.ActiveOwnerCount); + + activator.OnCreate(entity); + activator.OnCreate(entity); + Assert.Equal(2, effectAttempts); + Assert.Equal(2, animationAttempts); + Assert.Equal(1, p.Runner.ActiveOwnerCount); + } + + [Fact] + public void DatStaticRemoval_RetriesOnlyIncompleteReleaseStages() + { + var p = BuildPipeline(); + WorldEntity entity = MakeDatStatic(0x40A9B422u, Vector3.Zero); + var setup = new DatReaderWriter.DBObjs.Setup(); + ScriptActivationInfo info = new( + ScriptId: 0, + PartTransforms: Array.Empty(), + EffectProfile: EntityEffectProfile.CreateDatStatic(setup), + Setup: setup, + DefaultAnimationId: 0x03000001u, + UsesStaticAnimationWorkset: true); + int animationReleaseAttempts = 0; + int effectReleaseAttempts = 0; + var activator = new EntityScriptActivator( + p.Runner, + p.Sink, + p.Poses, + _ => info, + registerDatStaticEffectOwner: (_, _, _) => { }, + unregisterDatStaticEffectOwner: _ => + { + effectReleaseAttempts++; + if (effectReleaseAttempts == 1) + throw new InvalidOperationException("effect release fault"); + }, + registerDatStaticAnimationOwner: (_, _) => { }, + unregisterDatStaticAnimationOwner: _ => + { + animationReleaseAttempts++; + if (animationReleaseAttempts == 1) + throw new InvalidOperationException("animation release fault"); + }); + activator.OnCreate(entity); + + Assert.Throws(() => activator.OnRemove(entity)); + Assert.Equal(1, animationReleaseAttempts); + Assert.Equal(0, effectReleaseAttempts); + + Assert.Throws(() => activator.OnRemove(entity)); + Assert.Equal(2, animationReleaseAttempts); + Assert.Equal(1, effectReleaseAttempts); + + activator.OnRemove(entity); + activator.OnRemove(entity); + Assert.Equal(2, animationReleaseAttempts); + Assert.Equal(2, effectReleaseAttempts); + } + [Fact] public void CollidingDatEntityIds_FailFastAtAllocatorBoundary() { diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs index c8af583c..1af6e7d3 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs @@ -797,6 +797,7 @@ public sealed class LiveEntityLifecycleStressTests public Vector3 LastServerPosition { get; set; } public double LastServerPositionTime { get; set; } public Vector3 LastShadowSyncPosition { get; set; } + public Quaternion LastShadowSyncOrientation { get; set; } public void BindCanonicalCell(Func read, Action write) { _readCell = read; diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs index 1d3b93dd..9be041f9 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -164,6 +164,113 @@ public sealed class LiveEntityPresentationControllerTests Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld); } + [Fact] + public void StateAcceptedDuringHiddenEffect_DrainsAfterCompleteHiddenTail() + { + Fixture fixture = new(PhysicsStateFlags.ReportCollisions); + Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); + bool nestedAccepted = false; + fixture.OnTypedPlay = type => + { + if (type != LiveEntityPresentationController.HiddenScriptType + || nestedAccepted) + { + return; + } + + nestedAccepted = true; + Assert.True(fixture.Runtime.TryApplyState( + new SetState.Parsed( + Fixture.Guid, + (uint)PhysicsStateFlags.ReportCollisions, + 1, + 3), + out _)); + Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid)); + }; + + Assert.True(fixture.Runtime.TryApplyState( + new SetState.Parsed( + Fixture.Guid, + (uint)(PhysicsStateFlags.Hidden + | PhysicsStateFlags.ReportCollisions), + 1, + 2), + out _)); + Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid)); + + Assert.True(nestedAccepted); + Assert.Equal( + [ + "effect:76", "children:True", "part-array", "target", + "effect:75", "children:False", "part-array", + ], + fixture.PresentationOrder); + Assert.True(fixture.Entity.IsDrawVisible); + Assert.Equal(1, fixture.Shadows.TotalRegistered); + } + + [Fact] + public void DeleteAndGuidReuseDuringHiddenEffect_StopsOldTransitionTail() + { + Fixture fixture = new(PhysicsStateFlags.ReportCollisions); + Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); + Assert.True(fixture.Runtime.TryGetRecord( + Fixture.Guid, + out LiveEntityRecord oldRecord)); + WorldEntity? replacement = null; + fixture.OnTypedPlay = type => + { + if (type != LiveEntityPresentationController.HiddenScriptType + || replacement is not null) + { + return; + } + + fixture.Controller.Forget(oldRecord); + fixture.Shadows.Deregister(fixture.Entity.Id); + Assert.True(fixture.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(Fixture.Guid, 1), + isLocalPlayer: false)); + fixture.Runtime.RegisterLiveEntity(Fixture.Spawn( + PhysicsStateFlags.ReportCollisions, + instanceSequence: 2)); + replacement = fixture.Runtime.MaterializeLiveEntity( + Fixture.Guid, + 0x01010001u, + id => new WorldEntity + { + Id = id, + ServerGuid = Fixture.Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(20f, 20f, 5f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }); + Assert.NotNull(replacement); + Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); + }; + + Assert.True(fixture.Runtime.TryApplyState( + new SetState.Parsed( + Fixture.Guid, + (uint)(PhysicsStateFlags.Hidden + | PhysicsStateFlags.ReportCollisions), + 1, + 2), + out _)); + Assert.False(fixture.Controller.OnStateAccepted(Fixture.Guid)); + + Assert.NotNull(replacement); + Assert.Equal(["effect:76"], fixture.PresentationOrder); + Assert.Empty(fixture.ChildNoDraw); + Assert.Empty(fixture.PartArrayEnterWorld); + Assert.Empty(fixture.InvalidTargets); + Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out var current)); + Assert.Same(replacement, current.WorldEntity); + Assert.NotSame(oldRecord, current); + } + [Fact] public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns() { @@ -200,6 +307,285 @@ public sealed class LiveEntityPresentationControllerTests Assert.Equal(1, fixture.Shadows.TotalRegistered); } + [Fact] + public void VisibleOrdinaryProjectionPending_SuspendsAndHydrationRestoresShadow() + { + Fixture fixture = new(PhysicsStateFlags.ReportCollisions); + Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); + Assert.Equal(1, fixture.Shadows.TotalRegistered); + + Assert.True(fixture.Runtime.RebucketLiveEntity( + Fixture.Guid, + 0x02020001u)); + + Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid)); + Assert.Equal(0, fixture.Shadows.TotalRegistered); + Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount); + Assert.Equal(1, fixture.Shadows.SuspendedRegistrationCount); + Assert.True(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid)); + + fixture.Spatial.AddLandblock(new LoadedLandblock( + 0x0202FFFFu, + new LandBlock(), + Array.Empty())); + + Assert.Same( + fixture.Entity, + Assert.Single(fixture.Runtime.WorldEntities).Value); + Assert.Equal(1, fixture.Shadows.TotalRegistered); + Assert.Equal(0, fixture.Shadows.SuspendedRegistrationCount); + Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid)); + } + + [Fact] + public void PendingVisibilityCallback_GuidReuseCannotRestoreOldShadowOwner() + { + Fixture fixture = new(PhysicsStateFlags.ReportCollisions); + Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); + Assert.True(fixture.Runtime.TryGetRecord( + Fixture.Guid, + out LiveEntityRecord oldRecord)); + WorldEntity oldEntity = fixture.Entity; + WorldEntity? replacement = null; + + fixture.Runtime.ProjectionVisibilityChanged += (record, visible) => + { + if (visible + || replacement is not null + || !ReferenceEquals(record, oldRecord)) + { + return; + } + + fixture.Controller.Forget(oldRecord); + fixture.Shadows.Deregister(oldEntity.Id); + Assert.True(fixture.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(Fixture.Guid, 1), + isLocalPlayer: false)); + fixture.Runtime.RegisterLiveEntity(Fixture.Spawn( + PhysicsStateFlags.ReportCollisions, + instanceSequence: 2)); + replacement = fixture.Runtime.MaterializeLiveEntity( + Fixture.Guid, + 0x01010001u, + id => new WorldEntity + { + Id = id, + ServerGuid = Fixture.Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(20f, 20f, 5f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }); + Assert.NotNull(replacement); + fixture.Shadows.Register( + replacement.Id, + 0x01000001u, + replacement.Position, + replacement.Rotation, + 1f, + 0f, + 0f, + 0x01010000u, + state: (uint)PhysicsStateFlags.ReportCollisions, + seedCellId: 0x01010001u); + Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); + }; + + Assert.False(fixture.Runtime.RebucketLiveEntity( + Fixture.Guid, + 0x02020001u)); + + Assert.NotNull(replacement); + Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out var current)); + Assert.NotSame(oldRecord, current); + Assert.Same(replacement, current.WorldEntity); + Assert.Equal(1, fixture.Shadows.TotalRegistered); + Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount); + Assert.Equal(0, fixture.Shadows.SuspendedRegistrationCount); + Assert.Contains( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == replacement.Id); + Assert.DoesNotContain( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == oldEntity.Id); + Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid)); + } + + [Fact] + public void InitialPendingProjection_ReadyBarrierSuspendsAndHydrationRestoresShadow() + { + const uint pendingCell = 0x02020001u; + var spatial = new GpuWorldState(); + var shadows = new ShadowObjectRegistry(); + var runtime = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + runtime.RegisterLiveEntity(Fixture.Spawn( + PhysicsStateFlags.ReportCollisions)); + WorldEntity entity = Assert.IsType( + runtime.MaterializeLiveEntity( + Fixture.Guid, + pendingCell, + id => new WorldEntity + { + Id = id, + ServerGuid = Fixture.Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 10f, 5f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = pendingCell, + })); + shadows.Register( + entity.Id, + 0x01000001u, + entity.Position, + entity.Rotation, + 1f, + 0f, + 0f, + 0x02020000u, + state: (uint)PhysicsStateFlags.ReportCollisions, + seedCellId: pendingCell); + using var controller = new LiveEntityPresentationController( + runtime, + shadows, + (_, _, _) => true, + liveCenter: () => (2, 2)); + + Assert.True(controller.OnLiveEntityReady(Fixture.Guid)); + + Assert.Equal(0, shadows.TotalRegistered); + Assert.Equal(1, shadows.RetainedRegistrationCount); + Assert.Equal(1, shadows.SuspendedRegistrationCount); + Assert.True(controller.HasDeferredShadowRestore(Fixture.Guid)); + + spatial.AddLandblock(new LoadedLandblock( + 0x0202FFFFu, + new LandBlock(), + Array.Empty())); + + Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value); + Assert.Equal(1, shadows.TotalRegistered); + Assert.Equal(0, shadows.SuspendedRegistrationCount); + Assert.False(controller.HasDeferredShadowRestore(Fixture.Guid)); + } + + [Fact] + public void InitialPendingHydrationCallback_GuidReuseCannotRestoreOldShadow() + { + const uint pendingCell = 0x02020001u; + var spatial = new GpuWorldState(); + var shadows = new ShadowObjectRegistry(); + var runtime = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + runtime.RegisterLiveEntity(Fixture.Spawn( + PhysicsStateFlags.ReportCollisions)); + WorldEntity oldEntity = Assert.IsType( + runtime.MaterializeLiveEntity( + Fixture.Guid, + pendingCell, + id => new WorldEntity + { + Id = id, + ServerGuid = Fixture.Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 10f, 5f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = pendingCell, + })); + Assert.True(runtime.TryGetRecord(Fixture.Guid, out LiveEntityRecord oldRecord)); + shadows.Register( + oldEntity.Id, + 0x01000001u, + oldEntity.Position, + oldEntity.Rotation, + 1f, + 0f, + 0f, + 0x02020000u, + state: (uint)PhysicsStateFlags.ReportCollisions, + seedCellId: pendingCell); + + LiveEntityPresentationController? controller = null; + WorldEntity? replacement = null; + runtime.ProjectionVisibilityChanged += (record, visible) => + { + if (!visible + || replacement is not null + || !ReferenceEquals(record, oldRecord)) + { + return; + } + + controller!.Forget(oldRecord); + shadows.Deregister(oldEntity.Id); + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(Fixture.Guid, 1), + isLocalPlayer: false)); + runtime.RegisterLiveEntity(Fixture.Spawn( + PhysicsStateFlags.ReportCollisions, + instanceSequence: 2)); + replacement = runtime.MaterializeLiveEntity( + Fixture.Guid, + pendingCell, + id => new WorldEntity + { + Id = id, + ServerGuid = Fixture.Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(20f, 20f, 5f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = pendingCell, + }); + Assert.NotNull(replacement); + shadows.Register( + replacement.Id, + 0x01000001u, + replacement.Position, + replacement.Rotation, + 1f, + 0f, + 0f, + 0x02020000u, + state: (uint)PhysicsStateFlags.ReportCollisions, + seedCellId: pendingCell); + Assert.True(controller.OnLiveEntityReady(Fixture.Guid)); + }; + controller = new LiveEntityPresentationController( + runtime, + shadows, + (_, _, _) => true, + liveCenter: () => (2, 2)); + Assert.True(controller.OnLiveEntityReady(Fixture.Guid)); + Assert.Equal(1, shadows.SuspendedRegistrationCount); + + spatial.AddLandblock(new LoadedLandblock( + 0x0202FFFFu, + new LandBlock(), + Array.Empty())); + + Assert.NotNull(replacement); + Assert.True(runtime.TryGetRecord(Fixture.Guid, out var current)); + Assert.NotSame(oldRecord, current); + Assert.Same(replacement, current.WorldEntity); + Assert.Equal(1, shadows.TotalRegistered); + Assert.Equal(1, shadows.RetainedRegistrationCount); + Assert.Equal(0, shadows.SuspendedRegistrationCount); + Assert.Contains( + shadows.AllEntriesForDebug(), + entry => entry.EntityId == replacement.Id); + Assert.DoesNotContain( + shadows.AllEntriesForDebug(), + entry => entry.EntityId == oldEntity.Id); + Assert.False(controller.HasDeferredShadowRestore(Fixture.Guid)); + controller.Dispose(); + } + [Fact] public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset() { @@ -260,6 +646,7 @@ public sealed class LiveEntityPresentationControllerTests public ShadowObjectRegistry Shadows { get; } = new(); public WorldEntity Entity { get; } public LiveEntityPresentationController Controller { get; } + public Action? OnTypedPlay { get; set; } public Fixture( PhysicsStateFlags initialState, @@ -306,6 +693,7 @@ public sealed class LiveEntityPresentationControllerTests { TypedPlays.Add((owner, type, intensity)); PresentationOrder.Add($"effect:{type:X2}"); + OnTypedPlay?.Invoke(type); return true; }, (parent, noDraw) => diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index 03345ed6..f0da5d2b 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -1,5 +1,7 @@ +using System.Numerics; using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.App.Rendering; using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Vfx; using AcDream.Core.Net; @@ -189,6 +191,195 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(new[] { false, true }, visibilityEdges); } + [Fact] + public void StaticRootCommit_HiddenObjectUpdatesPoseWithoutRestoringShadow() + { + const uint guid = 0x70000071u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + WorldSession.EntitySpawn spawn = Spawn( + guid, + instance: 1, + positionSequence: 1, + cell: 0x01010001u, + state: PhysicsStateFlags.Static | PhysicsStateFlags.ReportCollisions); + runtime.RegisterLiveEntity(spawn); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + entity.Position = new Vector3(12f, 12f, 5f); + PhysicsBody body = runtime.GetOrCreatePhysicsBody( + guid, + _ => + { + var created = new PhysicsBody(); + created.SnapToCell( + 0x01010001u, + entity.Position, + new Vector3(12f, 12f, 5f)); + return created; + }); + var shadows = new ShadowObjectRegistry(); + shadows.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + new[] + { + new ShadowShape( + 0x01000001u, + Vector3.UnitX, + Quaternion.Identity, + Scale: 1f, + CollisionType: ShadowCollisionType.Cylinder, + Radius: 0.5f, + CylHeight: 1f), + }, + state: (uint)PhysicsStateFlags.ReportCollisions, + flags: EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + seedCellId: 0x01010001u); + int poseUpdates = 0; + var committer = new StaticLiveRootCommitter( + () => runtime, + shadows, + () => (1, 1), + _ => poseUpdates++); + + body.SetFrameInCurrentCell( + body.Position, + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f)); + entity.Rotation = body.Orientation; + Assert.True(committer.Commit(entity, body)); + ShadowEntry visible = Assert.Single(shadows.AllEntriesForDebug()); + Assert.InRange(visible.Position.X, 11.999f, 12.001f); + Assert.InRange(visible.Position.Y, 12.999f, 13.001f); + + Assert.True(runtime.TryApplyState(new SetState.Parsed( + guid, + (uint)(PhysicsStateFlags.Static + | PhysicsStateFlags.Hidden + | PhysicsStateFlags.IgnoreCollisions), + InstanceSequence: 1, + StateSequence: 2), out _)); + Assert.True(shadows.Suspend(entity.Id)); + body.SetFrameInCurrentCell( + body.Position, + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI)); + entity.Rotation = body.Orientation; + + Assert.True(committer.Commit(entity, body)); + Assert.Equal(2, poseUpdates); + Assert.Empty(shadows.AllEntriesForDebug()); + Assert.Equal(1, shadows.SuspendedRegistrationCount); + } + + [Fact] + public void StaticRootCommit_BeforeLiveRuntimeCompositionIsSafeNoOp() + { + LiveEntityRuntime? runtime = null; + int poseUpdates = 0; + var committer = new StaticLiveRootCommitter( + () => runtime, + new ShadowObjectRegistry(), + () => (0, 0), + _ => poseUpdates++); + + Assert.False(committer.Commit( + Entity(0x7F000001u, 0x70000001u), + new PhysicsBody())); + Assert.Equal(0, poseUpdates); + } + + [Fact] + public void StaticRootCommit_EffectCallbackReplacesGuid_DoesNotRestoreOldShadow() + { + const uint guid = 0x70000074u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn( + guid, + instance: 1, + positionSequence: 1, + cell: 0x01010001u, + state: PhysicsStateFlags.Static | PhysicsStateFlags.ReportCollisions)); + WorldEntity oldEntity = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + oldEntity.Position = new Vector3(12f, 12f, 5f); + PhysicsBody oldBody = runtime.GetOrCreatePhysicsBody( + guid, + _ => + { + var created = new PhysicsBody(); + created.SnapToCell( + 0x01010001u, + oldEntity.Position, + oldEntity.Position); + return created; + }); + var shadows = new ShadowObjectRegistry(); + shadows.RegisterMultiPart( + oldEntity.Id, + oldEntity.Position, + oldEntity.Rotation, + new[] + { + new ShadowShape( + 0x01000001u, + Vector3.UnitX, + Quaternion.Identity, + Scale: 1f, + CollisionType: ShadowCollisionType.Cylinder, + Radius: 0.5f, + CylHeight: 1f), + }, + state: (uint)PhysicsStateFlags.ReportCollisions, + flags: EntityCollisionFlags.None, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0x01010000u, + seedCellId: 0x01010001u); + var committer = new StaticLiveRootCommitter( + () => runtime, + shadows, + () => (1, 1), + _ => + { + Assert.True(shadows.Suspend(oldEntity.Id)); + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + runtime.RegisterLiveEntity(Spawn( + guid, + instance: 2, + positionSequence: 1, + cell: 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + }); + + oldBody.SetFrameInCurrentCell( + oldBody.Position, + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f)); + oldEntity.Rotation = oldBody.Orientation; + + Assert.False(committer.Commit(oldEntity, oldBody)); + Assert.Empty(shadows.AllEntriesForDebug()); + Assert.Equal(1, shadows.SuspendedRegistrationCount); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)2, replacement.Generation); + Assert.NotSame(oldEntity, replacement.WorldEntity); + } + [Fact] public void SpatialComponentWorksets_FollowLoadedProjectionWhileLogicalOwnersSurvive() { @@ -313,6 +504,41 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(2, runtime.AnimationRuntimes.Count); } + [Fact] + public void AnimationView_HotSpatialTraversalDoesNotAllocateAfterWarmup() + { + const uint guid = 0x70000075u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity entity = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity)); + var view = new LiveEntityAnimationRuntimeView( + () => runtime); + var ids = new HashSet(); + + view.CopySpatialIdsTo(ids); + foreach (KeyValuePair _ in view) { } + + int visits = 0; + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + { + view.CopySpatialIdsTo(ids); + foreach (KeyValuePair _ in view) + visits++; + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + Assert.Equal(1_000, visits); + Assert.Equal(entity.Id, Assert.Single(ids)); + } + [Fact] public void SpatialComponentWorksets_IsolateGuidReuseAcrossVisibilityCallbacks() { @@ -350,6 +576,60 @@ public sealed class LiveEntityRuntimeTests Assert.NotSame(oldAnimation, indexed.Value); } + [Fact] + public void CanonicalPhysicsBody_IsCreatedOncePerIncarnationAndLaterReused() + { + const uint guid = 0x70000073u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + int factoryCalls = 0; + + PhysicsBody first = runtime.GetOrCreatePhysicsBody( + guid, + _ => + { + factoryCalls++; + return new PhysicsBody(); + }); + PhysicsBody retained = runtime.GetOrCreatePhysicsBody( + guid, + _ => + { + factoryCalls++; + return new PhysicsBody(); + }); + + Assert.Same(first, retained); + Assert.Equal(1, factoryCalls); + Assert.True(runtime.TryGetRecord(guid, out var firstRecord)); + Assert.Same(first, firstRecord.PhysicsBody); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + PhysicsBody replacement = runtime.GetOrCreatePhysicsBody( + guid, + _ => + { + factoryCalls++; + return new PhysicsBody(); + }); + + Assert.NotSame(first, replacement); + Assert.Equal(2, factoryCalls); + } + [Fact] public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown() { @@ -667,15 +947,24 @@ public sealed class LiveEntityRuntimeTests id => Entity(id, guid))!; var animation = new AnimationRuntime(entity); runtime.SetAnimationRuntime(guid, animation); + var remote = new RemoteMotionRuntime(); + runtime.SetRemoteMotionRuntime(guid, remote); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + ulong initialClockEpoch = record.ObjectClockEpoch; + Assert.True(record.ObjectClock.IsActive); + Assert.True(remote.Body.IsActive); uint localId = entity.Id; Assert.True(runtime.TryMarkWorldSpawnPublished(guid)); Assert.True(runtime.TryApplyPickup( new PickupEvent.Parsed(guid, spawn.InstanceSequence, 11), out _)); + Assert.False(record.ObjectClock.IsActive); + Assert.False(remote.Body.IsActive); + Assert.Equal(initialClockEpoch + 1, record.ObjectClockEpoch); Assert.True(runtime.WithdrawLiveEntityProjection(guid)); + Assert.Equal(initialClockEpoch + 1, record.ObjectClockEpoch); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); Assert.Equal(0u, record.FullCellId); Assert.Equal(0u, record.CanonicalLandblockId); Assert.False(runtime.ShouldAdvanceRootRuntime(guid)); @@ -711,6 +1000,9 @@ public sealed class LiveEntityRuntimeTests Assert.Same(entity, same); Assert.Equal(localId, same.Id); + Assert.True(record.ObjectClock.IsActive); + Assert.True(remote.Body.IsActive); + Assert.Equal(initialClockEpoch + 2, record.ObjectClockEpoch); Assert.False(runtime.TryMarkWorldSpawnPublished(guid)); Assert.True(runtime.ShouldAdvanceRootRuntime(guid)); Assert.Equal(0x01010002u, record.FullCellId); @@ -760,6 +1052,267 @@ public sealed class LiveEntityRuntimeTests Assert.True(runtime.ShouldAdvanceRootRuntime(guid)); } + [Fact] + public void ObjectClock_SurvivesLoadedRebucketAndRebasesPendingReentry() + { + const uint guid = 0x70000047u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); + LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!; + runtime.MaterializeLiveEntity( + guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, guid)); + RetailObjectQuantumClock clock = record.ObjectClock; + Assert.Equal(0, clock.Advance(0.02).Count); + + Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u)); + Assert.Same(clock, record.ObjectClock); + Assert.True(clock.IsActive); + Assert.Equal(0.02, clock.PendingSeconds, 8); + + Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u)); + Assert.False(clock.IsActive); + Assert.Equal(0.02, clock.PendingSeconds, 8); + spatial.AddLandblock(EmptyLandblock(0x0202FFFFu)); + Assert.True(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + + Assert.Equal( + RetailObjectActivityResult.Active, + RetailObjectActivityGate.Evaluate( + clock, + body: null, + lifecycleEligible: runtime.ShouldAdvanceRootRuntime(guid), + hasPartArray: true, + isStatic: false, + objectPosition: Vector3.Zero, + playerPosition: Vector3.Zero, + elapsedSeconds: 0.1)); + Assert.Same(clock, record.ObjectClock); + Assert.Equal(0.0, clock.PendingSeconds, 8); + } + + [Fact] + public void InitiallyVisibleStaticObject_RebasesWithoutBecomingActive() + { + const uint guid = 0x70000049u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + WorldSession.EntitySpawn spawn = Spawn( + guid, + 1, + 1, + 0x01010001u, + PhysicsStateFlags.Static); + LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!; + var remote = new RemoteMotionRuntime(); + runtime.SetRemoteMotionRuntime(guid, remote); + + runtime.MaterializeLiveEntity( + guid, + spawn.Position!.Value.LandblockId, + id => Entity(id, guid)); + + Assert.False(record.ObjectClock.IsActive); + Assert.Equal(0.0, record.ObjectClock.PendingSeconds, 8); + Assert.False(remote.Body.TransientState.HasFlag(TransientStateFlags.Active)); + } + + [Fact] + public void MovementActivation_ReactivatesExactIncarnation_ButStaticIsNoOp() + { + const uint ordinaryGuid = 0x70000050u; + const uint staticGuid = 0x70000051u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + + LiveEntityRecord ordinary = runtime.RegisterLiveEntity( + Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!; + var ordinaryRemote = new RemoteMotionRuntime(); + runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote); + runtime.MaterializeLiveEntity( + ordinaryGuid, + 0x01010001u, + id => Entity(id, ordinaryGuid)); + ordinary.ObjectClock.Deactivate(); + ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active; + + Assert.True(runtime.TryActivateOrdinaryObject(ordinaryGuid, ordinaryRemote)); + Assert.True(ordinary.ObjectClock.IsActive); + Assert.True(ordinaryRemote.Body.TransientState.HasFlag(TransientStateFlags.Active)); + + LiveEntityRecord staticRecord = runtime.RegisterLiveEntity( + Spawn(staticGuid, 1, 1, 0x01010001u, PhysicsStateFlags.Static)).Record!; + var staticRemote = new RemoteMotionRuntime(); + runtime.SetRemoteMotionRuntime(staticGuid, staticRemote); + runtime.MaterializeLiveEntity( + staticGuid, + 0x01010001u, + id => Entity(id, staticGuid)); + + Assert.False(runtime.TryActivateOrdinaryObject(staticGuid, staticRemote)); + Assert.False(staticRecord.ObjectClock.IsActive); + Assert.False(staticRemote.Body.TransientState.HasFlag(TransientStateFlags.Active)); + } + + [Fact] + public void AuthoritativeVector_WakesOrdinaryClockButPreservesStaticActivity() + { + const uint ordinaryGuid = 0x70000052u; + const uint defensiveGuid = 0x70000053u; + const uint staticGuid = 0x70000054u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + + LiveEntityRecord ordinary = runtime.RegisterLiveEntity( + Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!; + var ordinaryRemote = new RemoteMotionRuntime(); + runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote); + runtime.MaterializeLiveEntity( + ordinaryGuid, + 0x01010001u, + id => Entity(id, ordinaryGuid)); + ordinary.ObjectClock.Deactivate(); + ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active; + ordinaryRemote.Body.LastUpdateTime = 1.0; + + Assert.True(runtime.TryCommitAuthoritativeVector( + ordinary, + ordinaryRemote.Body, + new Vector3(1f, 2f, 3f), + new Vector3(4f, 5f, 6f), + currentTime: 8.0)); + Assert.True(ordinary.ObjectClock.IsActive); + Assert.True(ordinaryRemote.Body.IsActive); + Assert.Equal(8.0, ordinaryRemote.Body.LastUpdateTime); + Assert.Equal(new Vector3(1f, 2f, 3f), ordinaryRemote.Body.Velocity); + Assert.Equal(new Vector3(4f, 5f, 6f), ordinaryRemote.Body.Omega); + + // Defensive split state: the retained object clock is already active, + // but a body consumer cleared its legacy Active bit. set_velocity must + // still rebase that body's compatibility timestamp. + LiveEntityRecord defensive = runtime.RegisterLiveEntity( + Spawn(defensiveGuid, 1, 1, 0x01010001u)).Record!; + var defensiveRemote = new RemoteMotionRuntime(); + runtime.SetRemoteMotionRuntime(defensiveGuid, defensiveRemote); + runtime.MaterializeLiveEntity( + defensiveGuid, + 0x01010001u, + id => Entity(id, defensiveGuid)); + Assert.True(defensive.ObjectClock.IsActive); + defensiveRemote.Body.TransientState &= ~TransientStateFlags.Active; + defensiveRemote.Body.LastUpdateTime = 2.0; + + Assert.True(runtime.TryCommitAuthoritativeVelocity( + defensive, + defensiveRemote.Body, + Vector3.UnitX, + currentTime: 9.0)); + Assert.True(defensive.ObjectClock.IsActive); + Assert.True(defensiveRemote.Body.IsActive); + Assert.Equal(9.0, defensiveRemote.Body.LastUpdateTime); + + LiveEntityRecord staticRecord = runtime.RegisterLiveEntity( + Spawn( + staticGuid, + 1, + 1, + 0x01010001u, + PhysicsStateFlags.Static)).Record!; + var staticRemote = new RemoteMotionRuntime(); + runtime.SetRemoteMotionRuntime(staticGuid, staticRemote); + runtime.MaterializeLiveEntity( + staticGuid, + 0x01010001u, + id => Entity(id, staticGuid)); + staticRemote.Body.TransientState &= ~TransientStateFlags.Active; + staticRemote.Body.LastUpdateTime = 3.0; + + Assert.True(runtime.TryCommitAuthoritativeVector( + staticRecord, + staticRemote.Body, + new Vector3(7f, 8f, 9f), + new Vector3(10f, 11f, 12f), + currentTime: 10.0)); + Assert.False(staticRecord.ObjectClock.IsActive); + Assert.False(staticRemote.Body.IsActive); + Assert.Equal(3.0, staticRemote.Body.LastUpdateTime); + Assert.Equal(new Vector3(7f, 8f, 9f), staticRemote.Body.Velocity); + Assert.Equal(new Vector3(10f, 11f, 12f), staticRemote.Body.Omega); + } + + [Fact] + public void ParentAndPickupInvalidateOlderPositionAndVelocityAuthority() + { + const uint parentGuid = 0x50000010u; + const uint childGuid = 0x70000055u; + var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(parentGuid, 1, 1, 0x01010001u)); + LiveEntityRecord child = runtime.RegisterLiveEntity( + Spawn(childGuid, 1, 1, 0x01010001u)).Record!; + ulong initialPosition = child.PositionAuthorityVersion; + ulong initialVelocity = child.VelocityAuthorityVersion; + + Assert.True(runtime.TryApplyParent( + new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: 0x00000001u, + PlacementId: 0x00000002u, + ParentInstanceSequence: 1, + ChildPositionSequence: 2), + out _)); + Assert.False(runtime.IsCurrentPositionAuthority(child, initialPosition)); + Assert.False(runtime.IsCurrentVelocityAuthority(child, initialVelocity)); + ulong parentedPosition = child.PositionAuthorityVersion; + ulong parentedVelocity = child.VelocityAuthorityVersion; + + Assert.True(runtime.TryApplyPickup( + new PickupEvent.Parsed(childGuid, 1, 3), + out _)); + Assert.False(runtime.IsCurrentPositionAuthority(child, parentedPosition)); + Assert.False(runtime.IsCurrentVelocityAuthority(child, parentedVelocity)); + } + + [Fact] + public void ObjectClock_WithdrawalRetainsIncarnationButGuidReuseGetsFreshOwner() + { + const uint guid = 0x70000048u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + WorldSession.EntitySpawn firstSpawn = Spawn(guid, 1, 1, 0x01010001u); + LiveEntityRecord first = runtime.RegisterLiveEntity(firstSpawn).Record!; + runtime.MaterializeLiveEntity( + guid, + firstSpawn.Position!.Value.LandblockId, + id => Entity(id, guid)); + RetailObjectQuantumClock firstClock = first.ObjectClock; + Assert.Equal(0, firstClock.Advance(0.02).Count); + + Assert.True(runtime.WithdrawLiveEntityProjection(guid)); + Assert.Same(firstClock, first.ObjectClock); + Assert.False(firstClock.IsActive); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + WorldSession.EntitySpawn secondSpawn = Spawn(guid, 2, 2, 0x01010001u); + LiveEntityRecord second = runtime.RegisterLiveEntity(secondSpawn).Record!; + + Assert.NotSame(first, second); + Assert.NotSame(firstClock, second.ObjectClock); + Assert.True(second.ObjectClock.IsActive); + Assert.Equal(0.0, second.ObjectClock.PendingSeconds, 8); + } + [Fact] public void PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder() { @@ -1625,6 +2178,94 @@ public sealed class LiveEntityRuntimeTests Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities)); } + [Fact] + public void DeferredWithdrawFalseEdge_RebucketSameRecordSupersedesOuterWithdrawal() + { + const uint guid = 0x70000075u; + var spatial = new GpuWorldState(); + var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity pending = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; + Assert.False(runtime.TryGetRecord(guid, out LiveEntityRecord before) + && before.IsSpatiallyVisible); + bool? nestedWithdrawResult = null; + bool nestedWithdrawStarted = false; + bool reprojected = false; + runtime.ProjectionVisibilityChanged += (_, visible) => + { + if (visible && !nestedWithdrawStarted) + { + // GpuWorldState is currently draining the outer visible edge, + // so Withdraw must manually publish its false edge. + nestedWithdrawStarted = true; + nestedWithdrawResult = runtime.WithdrawLiveEntityProjection(guid); + return; + } + if (!visible && !reprojected) + { + reprojected = true; + Assert.True(runtime.RebucketLiveEntity(guid, 0x01010022u)); + } + }; + + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + + Assert.False(nestedWithdrawResult); + Assert.True(reprojected); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.Equal(0x01010022u, record.FullCellId); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Same(pending, record.WorldEntity); + Assert.Same(pending, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Same(pending, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(pending, Assert.Single(spatial.Entities)); + } + + [Fact] + public void MaterializeVisibilityCallback_GuidReplacementNeverReturnsOldTombstone() + { + const uint guid = 0x70000076u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + var resources = new FailingOnceUnregisterResources(guid); + var runtime = new LiveEntityRuntime(spatial, resources); + runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + WorldEntity? replacementEntity = null; + bool replaced = false; + runtime.ProjectionVisibilityChanged += (edgeRecord, visible) => + { + if (replaced || !visible || edgeRecord.Generation != 1) + return; + replaced = true; + LiveEntityRegistrationResult replacement = runtime.RegisterLiveEntity( + Spawn(guid, 2, 1, 0x01010002u)); + Assert.NotNull(replacement.PriorGenerationCleanupFailure); + replacementEntity = runtime.MaterializeLiveEntity( + guid, + 0x01010002u, + id => Entity(id, guid)); + }; + + WorldEntity? displaced = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); + + Assert.Null(displaced); + Assert.NotNull(replacementEntity); + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + Assert.Equal((ushort)2, current.Generation); + Assert.Same(replacementEntity, current.WorldEntity); + Assert.Same(replacementEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Equal(1, runtime.PendingTeardownCount); + Assert.Equal(1, runtime.RetryPendingTeardowns()); + Assert.Equal(0, runtime.PendingTeardownCount); + } + [Fact] public void RebucketSpatialCallback_NewerSameRecordRebucketSupersedesOuterMove() { diff --git a/tests/AcDream.App.Tests/World/RetailInboundEventDispatcherTests.cs b/tests/AcDream.App.Tests/World/RetailInboundEventDispatcherTests.cs new file mode 100644 index 00000000..4377edae --- /dev/null +++ b/tests/AcDream.App.Tests/World/RetailInboundEventDispatcherTests.cs @@ -0,0 +1,112 @@ +using AcDream.App.World; + +namespace AcDream.App.Tests.World; + +public sealed class RetailInboundEventDispatcherTests +{ + private sealed class Counter + { + public int Value; + } + + [Fact] + public void NestedEvents_DrainAfterCompleteOuterTail_InArrivalOrder() + { + var dispatcher = new RetailInboundEventDispatcher(); + var calls = new List(); + + dispatcher.Run(() => + { + calls.Add("position-head"); + dispatcher.Run(() => calls.Add("state")); + dispatcher.Run(() => calls.Add("vector")); + calls.Add("position-tail"); + }); + + Assert.Equal( + ["position-head", "position-tail", "state", "vector"], + calls); + Assert.False(dispatcher.IsDraining); + Assert.Equal(0, dispatcher.PendingCount); + } + + [Fact] + public void FrameOperation_DefersInboundMutationUntilQuantumTail() + { + var dispatcher = new RetailInboundEventDispatcher(); + var calls = new List(); + + dispatcher.Run(() => + { + calls.Add("physics"); + dispatcher.Run(() => calls.Add("position")); + calls.Add("shadow-and-hooks"); + }); + + Assert.Equal(["physics", "shadow-and-hooks", "position"], calls); + } + + [Fact] + public void NestedStateOperations_DrainAfterOuterTail_InArrivalOrder() + { + var dispatcher = new RetailInboundEventDispatcher(); + var calls = new List(); + + dispatcher.Run( + dispatcher, + calls, + static (active, output) => + { + output.Add("position-head"); + active.Run( + output, + "state", + static (target, value) => target.Add(value)); + output.Add("position-tail"); + }); + + Assert.Equal(["position-head", "position-tail", "state"], calls); + Assert.False(dispatcher.IsDraining); + Assert.Equal(0, dispatcher.PendingCount); + } + + [Fact] + public void Failure_DiscardsQueuedTailAndLeavesDispatcherReusable() + { + var dispatcher = new RetailInboundEventDispatcher(); + bool queuedRan = false; + + Assert.Throws(() => dispatcher.Run(() => + { + dispatcher.Run(() => queuedRan = true); + throw new InvalidOperationException("packet failed"); + })); + + Assert.False(queuedRan); + Assert.False(dispatcher.IsDraining); + Assert.Equal(0, dispatcher.PendingCount); + + dispatcher.Run(() => queuedRan = true); + Assert.True(queuedRan); + } + + [Fact] + public void StateFastPath_DoesNotAllocateAfterWarmup() + { + var dispatcher = new RetailInboundEventDispatcher(); + var counter = new Counter(); + Action increment = + static (target, amount) => target.Value += amount; + dispatcher.Run(counter, 1, increment); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + { + dispatcher.Run(counter, 1, increment); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + Assert.Equal(1_001, counter.Value); + } +} diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index 3829f543..96d2d9aa 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -8,6 +8,8 @@ namespace AcDream.Core.Tests.Input; public class PlayerMovementControllerTests { + private static float ObjectTick => PhysicsBody.MinQuantum + 0.001f; + private static PhysicsEngine MakeFlatEngine() { var engine = new PhysicsEngine(); @@ -21,6 +23,29 @@ public class PlayerMovementControllerTests return engine; } + [Fact] + public void PerformMovement_ReactivatesCanonicalClock_ExceptForStaticObject() + { + var clock = new RetailObjectQuantumClock(); + var controller = new PlayerMovementController(MakeFlatEngine(), clock); + clock.Deactivate(); + controller.ApplyPhysicsState(PhysicsStateFlags.None); + + controller.Movement.PerformMovement(new MovementStruct + { + Type = (MovementType)99, + }); + Assert.True(clock.IsActive); + + clock.Deactivate(); + controller.ApplyPhysicsState(PhysicsStateFlags.Static); + controller.Movement.PerformMovement(new MovementStruct + { + Type = (MovementType)99, + }); + Assert.False(clock.IsActive); + } + [Fact] public void Update_NoInput_PositionUnchanged() { @@ -156,10 +181,10 @@ public class PlayerMovementControllerTests var start = new Vector3(96f, 96f, 50f); controller.SetPosition(start, 0x0001); controller.Yaw = 0f; - controller.AttachAnimationRootMotionSource(_ => Vector3.Zero); + controller.AttachAnimationRootMotionSource((_, _) => { }); MovementResult result = controller.Update( - PhysicsBody.MinQuantum, + ObjectTick, new MovementInput(Forward: true)); Assert.Equal(start, result.Position); @@ -175,10 +200,11 @@ public class PlayerMovementControllerTests controller.SetPosition(start, 0x0001); controller.Yaw = 0f; controller.ObjectScale = 2f; - controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.1f, 0f)); + controller.AttachAnimationRootMotionSource((_, frame) => + frame.Origin = new Vector3(0f, 0.1f, 0f)); MovementResult result = controller.Update( - PhysicsBody.MinQuantum, + ObjectTick, new MovementInput(Forward: true)); // Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles @@ -188,24 +214,313 @@ public class PlayerMovementControllerTests } [Fact] - public void Update_SubQuantumAnimationRootDeltas_AccumulateIntoOnePhysicsStep() + public void Update_SubQuantumFrames_AdvanceAnimationOnceAtObjectThreshold() { var controller = new PlayerMovementController(MakeFlatEngine()); var start = new Vector3(96f, 96f, 50f); controller.SetPosition(start, 0x0001); controller.Yaw = 0f; - controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.05f, 0f)); + int advances = 0; + controller.AttachAnimationRootMotionSource((_, frame) => + { + advances++; + frame.Origin = new Vector3(0f, 0.1f, 0f); + }); MovementResult first = controller.Update( - PhysicsBody.MinQuantum * 0.5f, + ObjectTick * 0.5f, new MovementInput(Forward: true)); MovementResult second = controller.Update( - PhysicsBody.MinQuantum * 0.5f, + ObjectTick * 0.5f, new MovementInput(Forward: true)); Assert.Equal(start, first.Position); Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3); Assert.Equal(start.Y, second.Position.Y, precision: 3); + Assert.Equal(1, advances); + } + + [Fact] + public void Update_AttachedAnimationFrame_ComposesTranslationBeforeTurn() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + var start = new Vector3(96f, 96f, 50f); + controller.SetPosition(start, 0x0001); + controller.Yaw = 0f; // body local +Y faces world +X + controller.AttachAnimationRootMotionSource((_, frame) => + { + frame.Origin = new Vector3(0f, 0.1f, 0f); + frame.Orientation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f); + }); + + MovementResult result = controller.Update( + ObjectTick, + new MovementInput()); + + // Retail Frame::combine transforms the delta origin by the OLD body + // orientation, then composes the delta orientation. The body therefore + // moves east before finishing the quarter-turn to north. + Assert.Equal(start.X + 0.1f, result.Position.X, precision: 3); + Assert.Equal(start.Y, result.Position.Y, precision: 3); + Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3); + } + + [Fact] + public void Update_AttachedAnimationFrame_PreservesCompleteNonCommutingOrientation() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f); + Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f); + controller.SetBodyOrientation(initial); + controller.AttachAnimationRootMotionSource((_, frame) => + frame.Orientation = delta); + + controller.Update(ObjectTick, new MovementInput()); + + Quaternion expected = Quaternion.Normalize(initial * delta); + float alignment = MathF.Abs(Quaternion.Dot( + expected, + Quaternion.Normalize(controller.BodyOrientation))); + Assert.InRange(alignment, 0.99999f, 1.00001f); + Assert.True(MathF.Abs(Quaternion.Dot( + Quaternion.Normalize(delta * initial), + Quaternion.Normalize(controller.BodyOrientation))) < 0.999f); + } + + [Fact] + public void Update_MultiQuantumAnimationFrames_ComposeInTemporalOrder() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + var start = new Vector3(96f, 96f, 50f); + controller.SetPosition(start, 0x0001); + controller.Yaw = 0f; + int sample = 0; + controller.AttachAnimationRootMotionSource((_, frame) => + { + if (sample++ == 0) + { + frame.Orientation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f); + } + else + { + frame.Origin = new Vector3(0f, 0.1f, 0f); + } + }); + + MovementResult result = controller.Update( + PhysicsBody.MaxQuantum * 2f, + new MovementInput()); + + // The second local-forward displacement occurs after the first + // quarter-turn, so it moves north. Adding Origins as bare vectors + // would incorrectly move east. + Assert.Equal(start.X, result.Position.X, precision: 3); + Assert.Equal(start.Y + 0.1f, result.Position.Y, precision: 3); + Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3); + } + + [Fact] + public void Update_ExactMinQuantum_RetainsTimeWithoutAdvancingObject() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + var start = new Vector3(96f, 96f, 50f); + controller.SetPosition(start, 0x0001); + int advances = 0; + controller.AttachAnimationRootMotionSource((_, frame) => + { + advances++; + frame.Origin = new Vector3(0f, 1f, 0f); + }); + + MovementResult atThreshold = controller.Update( + PhysicsBody.MinQuantum, + new MovementInput()); + + Assert.Equal(start, atThreshold.Position); + Assert.Equal(0, advances); + + controller.Update(0.001f, new MovementInput()); + Assert.Equal(1, advances); + } + + [Fact] + public void Update_LargeFrame_MatchesSeparateRetailObjectQuanta() + { + var combined = new PlayerMovementController(MakeFlatEngine()); + var split = new PlayerMovementController(MakeFlatEngine()); + var start = new Vector3(96f, 96f, 50f); + combined.SetPosition(start, 0x0001); + split.SetPosition(start, 0x0001); + int combinedHooks = 0; + int splitHooks = 0; + + static void Advance(float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame frame) + { + frame.Origin = new Vector3(0f, dt * 2f, 0f); + frame.Orientation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + dt * 0.7f); + } + + combined.AttachAnimationRootMotionSource(Advance, () => combinedHooks++); + split.AttachAnimationRootMotionSource(Advance, () => splitHooks++); + + combined.Update(PhysicsBody.MaxQuantum * 2f, new MovementInput()); + split.Update(PhysicsBody.MaxQuantum, new MovementInput()); + split.Update(PhysicsBody.MaxQuantum, new MovementInput()); + + Assert.Equal(split.Position.X, combined.Position.X, precision: 5); + Assert.Equal(split.Position.Y, combined.Position.Y, precision: 5); + Assert.Equal(split.Position.Z, combined.Position.Z, precision: 5); + float alignment = MathF.Abs(Quaternion.Dot( + Quaternion.Normalize(split.BodyOrientation), + Quaternion.Normalize(combined.BodyOrientation))); + Assert.InRange(alignment, 0.99999f, 1.00001f); + Assert.Equal(2, combinedHooks); + Assert.Equal(2, splitHooks); + } + + [Fact] + public void TickHidden_DoesNotAdvancePartArrayButStillProcessesHooks() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + int advances = 0; + int hookPasses = 0; + controller.AttachAnimationRootMotionSource( + (_, frame) => + { + advances++; + frame.Origin = new Vector3(0f, 1f, 0f); + }, + () => hookPasses++); + + controller.TickHidden(ObjectTick); + + Assert.Equal(0, advances); + Assert.Equal(1, hookPasses); + } + + [Fact] + public void InvalidElapsed_VisibleFrameIsPurePresentationRead() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + float initialTime = controller.SimTimeSeconds; + float initialYaw = controller.Yaw; + Vector3 initialPosition = controller.Position; + RawMotionState initialMotion = controller.Motion.RawState; + var hostileInput = new MovementInput( + Forward: true, + TurnLeft: true, + Jump: true, + Run: true); + + foreach (float elapsed in new[] + { + float.NaN, + float.PositiveInfinity, + float.NegativeInfinity, + -0.1f, + 0f, + }) + { + MovementResult result = controller.Update(elapsed, hostileInput); + Assert.False(result.ShouldSendMovementEvent); + } + + Assert.Equal(initialTime, controller.SimTimeSeconds); + Assert.Equal(initialYaw, controller.Yaw); + Assert.Equal(initialPosition, controller.Position); + Assert.Equal(initialMotion, controller.Motion.RawState); + + controller.Update(ObjectTick, new MovementInput()); + controller.Update(ObjectTick, new MovementInput()); + Assert.True(controller.AdvancedObjectQuantumLastTick); + controller.Update(float.NaN, hostileInput); + Assert.False(controller.AdvancedObjectQuantumLastTick); + } + + [Fact] + public void InvalidElapsed_HiddenFrameDoesNotAdvanceClockOrManagerTail() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + float initialTime = controller.SimTimeSeconds; + int targetPasses = 0; + + controller.TickHidden(float.NaN, () => targetPasses++); + controller.TickHidden(float.PositiveInfinity, () => targetPasses++); + controller.TickHidden(-0.1f, () => targetPasses++); + + Assert.Equal(initialTime, controller.SimTimeSeconds); + Assert.Equal(0, targetPasses); + Assert.False(controller.AdvancedObjectQuantumLastTick); + + controller.TickHidden(ObjectTick); + controller.TickHidden(ObjectTick); + Assert.True(controller.AdvancedObjectQuantumLastTick); + controller.TickHidden(float.PositiveInfinity); + Assert.False(controller.AdvancedObjectQuantumLastTick); + } + + [Fact] + public void Update_AirbornePartArrayFrame_SuppressesOriginButPreservesOrientation() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Update(1f, new MovementInput(Jump: true)); + + Vector3 beforeRelease = controller.Position; + Quaternion beforeOrientation = controller.BodyOrientation; + Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.4f); + int advances = 0; + controller.AttachAnimationRootMotionSource((_, frame) => + { + advances++; + frame.Origin = new Vector3(0f, 10f, 0f); + frame.Orientation = delta; + }); + + controller.Update(ObjectTick, new MovementInput(Jump: false)); + + Assert.True(controller.IsAirborne); + Assert.Equal(beforeRelease.X, controller.Position.X, precision: 5); + Assert.Equal(beforeRelease.Y, controller.Position.Y, precision: 5); + Quaternion expected = Quaternion.Normalize(beforeOrientation * delta); + float alignment = MathF.Abs(Quaternion.Dot( + expected, + Quaternion.Normalize(controller.BodyOrientation))); + Assert.InRange(alignment, 0.99999f, 1.00001f); + Assert.Equal(1, advances); + } + + [Fact] + public void Update_AttachedAnimationTurn_IsNotAppliedByASecondYawIntegrator() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; + controller.AttachAnimationRootMotionSource((dt, frame) => + { + frame.Orientation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + -(MathF.PI / 2f) * dt); + }); + + controller.Update( + ObjectTick, + new MovementInput(TurnRight: true)); + + Assert.Equal( + -(MathF.PI / 2f) * ObjectTick, + controller.Yaw, + precision: 4); } [Fact] @@ -217,7 +532,7 @@ public class PlayerMovementControllerTests controller.SetPosition(start, 0x0001); controller.Yaw = 0f; - var firstTick = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); + var firstTick = controller.Update(ObjectTick, new MovementInput(Forward: true)); Assert.True(firstTick.Position.X > start.X, "Physics tick should advance the authoritative body position"); Assert.Equal(start.X, firstTick.RenderPosition.X, precision: 4); @@ -240,7 +555,7 @@ public class PlayerMovementControllerTests controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.Yaw = 0f; - controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); + controller.Update(ObjectTick, new MovementInput(Forward: true)); controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true)); var snapped = new Vector3(120f, 80f, 50f); @@ -257,7 +572,7 @@ public class PlayerMovementControllerTests var controller = new PlayerMovementController(MakeFlatEngine()); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.Yaw = 0f; - controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); + controller.Update(ObjectTick, new MovementInput(Forward: true)); Vector3 velocity = controller.BodyVelocity; Assert.True(velocity.LengthSquared() > 0f); @@ -305,12 +620,25 @@ public class PlayerMovementControllerTests var controller = new PlayerMovementController(engine); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.Yaw = 0f; + int animationAdvances = 0; + int hookPasses = 0; + controller.AttachAnimationRootMotionSource( + (_, frame) => + { + animationAdvances++; + frame.Origin = new Vector3(0f, 0.1f, 0f); + }, + () => hookPasses++); - var moved = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); + var moved = controller.Update(ObjectTick, new MovementInput(Forward: true)); + int advancesBeforeDiscard = animationAdvances; + int hooksBeforeDiscard = hookPasses; var stale = controller.Update(PhysicsBody.HugeQuantum + 0.1f, new MovementInput(Forward: true)); Assert.Equal(moved.Position.X, stale.Position.X, precision: 4); Assert.Equal(stale.Position, stale.RenderPosition); + Assert.Equal(advancesBeforeDiscard, animationAdvances); + Assert.Equal(hooksBeforeDiscard, hookPasses); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 6de50f31..a25b563a 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -283,6 +283,27 @@ public sealed class AnimationSequencerTests $"Expected orientation near {rot.Z} but got {transforms[0].Orientation.Z}"); } + [Fact] + public void Constructor_InstallsSetupDefaultAnimationForEveryPartArray() + { + const uint AnimId = 0x0300AA01u; + var setup = Fixtures.MakeSetup(1); + setup.DefaultAnimation = (QualifiedDataId)AnimId; + var loader = new FakeLoader(); + loader.Register(AnimId, Fixtures.MakeTwoFrameAnim( + 1, + Vector3.Zero, + Quaternion.Identity, + new Vector3(2f, 0f, 0f), + Quaternion.Identity)); + var sequencer = new AnimationSequencer(setup, new MotionTable(), loader); + + Assert.True(sequencer.HasCurrentNode); + Assert.Equal(30f, sequencer.CurrentNodeDiag.Framerate); + Assert.Equal(0, sequencer.CurrentNodeDiag.StartFrame); + Assert.Equal(1, sequencer.CurrentNodeDiag.EndFrame); + } + [Fact] public void Advance_FrameWrapsAtHighFrame() { @@ -1883,6 +1904,36 @@ public sealed class AnimationSequencerTests // ── Helpers ────────────────────────────────────────────────────────────── + [Fact] + public void InitializeSetupDefaultAnimation_PreservesPhysicsAndPlacementState() + { + // CPartArray::InitDefaults (0x00518980) replaces only the animation + // list. It does not call CSequence::clear, so the PartArray's existing + // velocity, omega, and placement frame survive this operation. + const uint animationId = 0x03000531u; + var loader = new FakeLoader(); + loader.Register( + animationId, + Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); + var seq = new AnimationSequencer( + Fixtures.MakeSetup(1), + new MotionTable(), + loader); + var placement = new AnimationFrame(1); + Vector3 velocity = new(1f, 2f, 3f); + Vector3 omega = new(0.1f, 0.2f, 0.3f); + seq.Core.SetVelocity(velocity); + seq.Core.SetOmega(omega); + seq.Core.SetPlacementFrame(placement, 0x1234u); + + Assert.True(seq.InitializeSetupDefaultAnimation(animationId)); + + Assert.Equal(velocity, seq.CurrentVelocity); + Assert.Equal(omega, seq.CurrentOmega); + Assert.Same(placement, seq.Core.PlacementFrame); + Assert.Equal(0x1234u, seq.Core.PlacementFrameId); + } + /// /// Expose the core CSequence's FrameNumber via reflection (test-only). /// R1-P5 rehost (2026-07-02): _framePosition lived directly on diff --git a/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs b/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs index 3fd69470..4f47eee9 100644 --- a/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs @@ -1,6 +1,7 @@ using AcDream.Content.Vfx; using AcDream.Content; using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; using AcDream.Core.Tests.Conformance; using DatReaderWriter; using DatReaderWriter.DBObjs; @@ -23,6 +24,7 @@ public sealed class HumanoidMotionTableRootMotionTests private const uint RunForward = 0x40000007u; private const uint Ready = 0x41000003u; private const uint InterpretedWalkForward = 0x45000005u; + private const uint TurnRight = 0x6500000Du; [Theory] [InlineData(WalkForward)] @@ -76,4 +78,32 @@ public sealed class HumanoidMotionTableRootMotionTests distance > 1f, $"Installed retail WalkForward should author root travel; got {distance:F3} m over 2 s."); } + + [Fact] + public void RetailTurnRightModifier_ExposesLiteralSequenceOmega() + { + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) + return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + MotionTable table = Assert.IsType(dats.Get(HumanoidMotionTable)); + int styledKey = unchecked((int)((NonCombatStyle << 16) | (TurnRight & 0x00FF_FFFFu))); + int unstyledKey = unchecked((int)(TurnRight & 0x00FF_FFFFu)); + bool found = table.Modifiers.TryGetValue(styledKey, out MotionData? modifier) + || table.Modifiers.TryGetValue(unstyledKey, out modifier); + + Assert.True( + found, + $"Retail Humanoid TurnRight modifier was absent at styled key 0x{styledKey:X8} " + + $"and fallback key 0x{unstyledKey:X8}."); + Assert.NotNull(modifier); + + Assert.True( + modifier!.Flags.HasFlag(DatReaderWriter.Enums.MotionDataFlags.HasOmega), + $"Retail Humanoid TurnRight must mark its literal omega present; " + + $"flags={modifier.Flags}."); + Assert.Equal(System.Numerics.Vector3.Zero, modifier.Omega with { Z = 0f }); + Assert.Equal(-1.5f, modifier.Omega.Z, 5); + } } diff --git a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs index 721ddbc7..c70356ac 100644 --- a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs @@ -1,6 +1,7 @@ using System; using System.Numerics; using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; using Xunit; namespace AcDream.Core.Tests.Physics; @@ -27,6 +28,164 @@ public sealed class InterpolationManagerTests private static InterpolationManager Make() => new InterpolationManager(); + [Fact] + public void AdjustOffset_CompleteFrame_ReplacesOriginAndNonCommutingOrientation() + { + var manager = Make(); + Quaternion current = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f); + Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f); + manager.Enqueue( + new Vector3(10f, 0f, 0f), + target, + isMovingTo: false, + currentBodyPosition: Vector3.Zero); + var offset = new MotionDeltaFrame + { + Origin = new Vector3(7f, 8f, 9f), + Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.4f), + }; + + bool overwritten = manager.AdjustOffset( + dt: 0.1, + currentBodyPosition: Vector3.Zero, + currentBodyOrientation: current, + maxSpeedFromMinterp: 4f, + offset); + + Assert.True(overwritten); + Vector3 expectedLocalOrigin = MoveToMath.GlobalToLocalVec( + current, + new Vector3(0.8f, 0f, 0f)); + Assert.Equal(expectedLocalOrigin.X, offset.Origin.X, 5); + Assert.Equal(expectedLocalOrigin.Y, offset.Origin.Y, 5); + Assert.Equal(expectedLocalOrigin.Z, offset.Origin.Z, 5); + Quaternion expectedOrientation = Quaternion.Normalize( + Quaternion.Inverse(current) * target); + Assert.InRange( + MathF.Abs(Quaternion.Dot( + expectedOrientation, + Quaternion.Normalize(offset.Orientation))), + 0.99999f, + 1.00001f); + } + + [Fact] + public void AdjustOffset_MoveToNode_KeepsCurrentHeading() + { + var manager = Make(); + manager.Enqueue( + new Vector3(10f, 0f, 0f), + Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f), + isMovingTo: true, + currentBodyPosition: Vector3.Zero); + var offset = new MotionDeltaFrame + { + Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.6f), + }; + + Assert.True(manager.AdjustOffset( + 0.1, + Vector3.Zero, + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f), + 4f, + offset)); + + Assert.Equal(Quaternion.Identity, offset.Orientation); + } + + [Fact] + public void AdjustOffset_UsesManagerWideLatestKeepHeadingFlag() + { + var manager = Make(); + Quaternion current = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f); + manager.Enqueue( + new Vector3(10f, 0f, 0f), + Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.7f), + isMovingTo: false, + currentBodyPosition: Vector3.Zero, + currentBodyOrientation: current); + manager.Enqueue( + new Vector3(20f, 0f, 0f), + Quaternion.CreateFromAxisAngle(Vector3.UnitX, -0.6f), + isMovingTo: true, + currentBodyPosition: Vector3.Zero, + currentBodyOrientation: current); + var offset = new MotionDeltaFrame(); + + Assert.True(manager.AdjustOffset( + 0.1, + Vector3.Zero, + current, + 4f, + offset)); + + // keep_heading is an InterpolationManager field in retail, not a + // property of each queued node. The latest near enqueue therefore + // controls the current head as well. + Assert.Equal(Quaternion.Identity, offset.Orientation); + } + + [Fact] + public void AdjustOffset_WithoutContact_LeavesFrameAndQueueUntouched() + { + var manager = Make(); + manager.Enqueue( + FarTarget, + Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.7f), + isMovingTo: false, + currentBodyPosition: BodyOrigin); + var offset = new MotionDeltaFrame + { + Origin = new Vector3(1f, 2f, 3f), + Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.4f), + }; + Vector3 originalOrigin = offset.Origin; + Quaternion originalOrientation = offset.Orientation; + + bool overwritten = manager.AdjustOffset( + 0.1, + BodyOrigin, + Quaternion.Identity, + 4f, + offset, + inContact: false); + + Assert.False(overwritten); + Assert.Equal(originalOrigin, offset.Origin); + Assert.Equal(originalOrientation, offset.Orientation); + Assert.True(manager.IsActive); + } + + [Fact] + public void Enqueue_AlreadyClose_InstallsOnlyTargetHeading() + { + var manager = Make(); + Quaternion target = Quaternion.Normalize( + Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.8f) + * Quaternion.CreateFromAxisAngle(Vector3.UnitZ, -0.6f)); + + Quaternion? immediate = manager.Enqueue( + new Vector3(0.01f, 0f, 0f), + target, + isMovingTo: false, + currentBodyPosition: Vector3.Zero); + + Assert.NotNull(immediate); + Quaternion expected = MoveToMath.SetHeading( + target, + MoveToMath.GetHeading(target)); + Assert.InRange( + MathF.Abs(Quaternion.Dot( + Quaternion.Normalize(expected), + Quaternion.Normalize(immediate!.Value))), + 0.99999f, + 1.00001f); + Assert.True(MathF.Abs(Quaternion.Dot( + Quaternion.Normalize(target), + Quaternion.Normalize(immediate.Value))) < 0.999f); + Assert.False(manager.IsActive); + } + // ========================================================================= // Queue mechanics // ========================================================================= diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionDeltaFrameTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionDeltaFrameTests.cs new file mode 100644 index 00000000..d9e56893 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MotionDeltaFrameTests.cs @@ -0,0 +1,163 @@ +using System.Numerics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// Conformance coverage for retail Frame::combine (0x005122E0). +/// The production object tick accumulates one or more CSequence frames before +/// composing that delta onto the live body, so translation and orientation +/// must remain one indivisible transform. +/// +public sealed class MotionDeltaFrameTests +{ + [Fact] + public void Combine_MatchesRetailFrameOps_ForTranslationAndOrientation() + { + Quaternion baseRotation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f); + Quaternion deltaRotation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 4f); + + var expected = new Frame + { + Origin = new Vector3(10f, 20f, 30f), + Orientation = baseRotation, + }; + FrameOps.Combine(expected, new Frame + { + Origin = new Vector3(2f, 0f, 1f), + Orientation = deltaRotation, + }); + + var actual = new MotionDeltaFrame + { + Origin = new Vector3(10f, 20f, 30f), + Orientation = baseRotation, + }; + actual.Combine( + new Vector3(2f, 0f, 1f), + deltaRotation); + + AssertVectorEqual(expected.Origin, actual.Origin); + AssertQuaternionEquivalent(expected.Orientation, actual.Orientation); + } + + [Fact] + public void Combine_SequentialFrames_RotatesLaterTranslationByEarlierTurn() + { + var accumulated = new MotionDeltaFrame(); + accumulated.Combine( + Vector3.Zero, + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f)); + accumulated.Combine( + new Vector3(0f, 2f, 0f), + Quaternion.Identity); + + AssertVectorEqual(new Vector3(-2f, 0f, 0f), accumulated.Origin); + AssertQuaternionEquivalent( + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f), + accumulated.Orientation); + } + + [Fact] + public void Combine_NonCommutingRotations_UsesRetailPostMultiplyOrder() + { + Quaternion aroundX = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f); + Quaternion aroundY = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f); + var accumulated = new MotionDeltaFrame + { + Orientation = aroundX, + }; + + accumulated.Combine(Vector3.Zero, aroundY); + + AssertQuaternionEquivalent( + FrameOps.SetRotate(Vector3.Zero, aroundX, aroundX * aroundY), + accumulated.Orientation); + Assert.True(MathF.Abs(Quaternion.Dot( + accumulated.Orientation, + Quaternion.Normalize(aroundY * aroundX))) < 0.999f); + } + + [Theory] + [MemberData(nameof(InvalidRotations))] + public void Combine_InvalidCandidateRotation_RestoresPreviousQuaternion( + Quaternion invalid) + { + Quaternion previous = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.35f); + var accumulated = new MotionDeltaFrame + { + Orientation = previous, + }; + + accumulated.Combine(Vector3.Zero, invalid); + + AssertQuaternionEquivalent(previous, accumulated.Orientation); + } + + [Fact] + public void Combine_NearZeroNonzeroQuaternion_NormalizesLikeRetail() + { + var accumulated = new MotionDeltaFrame(); + var tiny = new Quaternion(1e-30f, 0f, 0f, 0f); + + accumulated.Combine(Vector3.Zero, tiny); + + AssertQuaternionEquivalent( + Quaternion.CreateFromAxisAngle(Vector3.UnitX, MathF.PI), + accumulated.Orientation); + } + + [Fact] + public void Combine_OriginScale_DoesNotScaleRotation() + { + var accumulated = new MotionDeltaFrame(); + Quaternion turn = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 3f); + + accumulated.Combine(new Vector3(0f, 0.5f, 0f), turn, originScale: 2f); + + AssertVectorEqual(new Vector3(0f, 1f, 0f), accumulated.Origin); + AssertQuaternionEquivalent(turn, accumulated.Orientation); + } + + [Fact] + public void Reset_RestoresIdentityFrame() + { + var frame = new MotionDeltaFrame + { + Origin = Vector3.One, + Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.5f), + }; + + frame.Reset(); + + Assert.Equal(Vector3.Zero, frame.Origin); + Assert.Equal(Quaternion.Identity, frame.Orientation); + } + + private static void AssertVectorEqual(Vector3 expected, Vector3 actual) + { + Assert.Equal(expected.X, actual.X, 5); + Assert.Equal(expected.Y, actual.Y, 5); + Assert.Equal(expected.Z, actual.Z, 5); + } + + private static void AssertQuaternionEquivalent(Quaternion expected, Quaternion actual) + { + float alignment = MathF.Abs(Quaternion.Dot( + Quaternion.Normalize(expected), + Quaternion.Normalize(actual))); + Assert.InRange(alignment, 0.99999f, 1.00001f); + } + + public static TheoryData InvalidRotations => new() + { + Quaternion.Zero, + new Quaternion(float.NaN, 0f, 0f, 1f), + new Quaternion(float.PositiveInfinity, 0f, 0f, 1f), + }; +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs index 4f039c6b..8c00d133 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs @@ -13,8 +13,7 @@ namespace AcDream.Core.Tests.Physics.Motion; /// R2-Q5 — : the funnel's dispatches go /// straight into (no axis /// collection, no priority pick, no fallback chain — GetObjectSequence -/// 0x00522860 + is_allowed decide) with the TurnApplied/TurnStopped -/// ObservedOmega seam. +/// 0x00522860 + is_allowed decide). /// public class MotionTableDispatchSinkTests { @@ -100,15 +99,10 @@ public class MotionTableDispatchSinkTests } [Fact] - public void ApplyMotion_Turn_Branch4Modifier_FiresTurnApplied_NoCycleChange() + public void ApplyMotion_Turn_Branch4Modifier_PreservesCycleAndAppliesDatOmega() { var seq = MakeSequencer(); - uint? turnMotion = null; - float turnSpeed = 0f; - var sink = new MotionTableDispatchSink(seq) - { - TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; }, - }; + var sink = new MotionTableDispatchSink(seq); sink.ApplyMotion(Walk, 1.0f); sink.ApplyMotion(TurnRight, 1.5f); @@ -116,22 +110,19 @@ public class MotionTableDispatchSinkTests // The AP-73 mechanism for real: substate cycle untouched, the turn // is a MotionState modifier with its dat omega combined. Assert.Equal(Walk, seq.CurrentMotion); - Assert.Equal((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed)); Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3); } [Fact] - public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics() + public void StopMotion_Turn_UnwindsModifierPhysics() { var seq = MakeSequencer(); - bool stopped = false; - var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true }; + var sink = new MotionTableDispatchSink(seq); sink.ApplyMotion(Walk, 1.0f); sink.ApplyMotion(TurnRight, 1.5f); sink.StopMotion(TurnRight); - Assert.True(stopped); // StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat // omega + modifier unlinked. Assert.Equal(0f, seq.CurrentOmega.Z, 3); diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs index cd9fe4f7..17282668 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs @@ -16,12 +16,9 @@ namespace AcDream.Core.Tests.Physics.Motion; /// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270. /// /// -/// The packer-reuse trap (V0-pins §P5 correction): acdream's -/// outbound packer (GameWindow.YawToAcQuaternion) is wire-correct at -/// the QUATERNION level but its internal scalar intermediate -/// (headingDeg = 180 - yawDeg) is holtburger's shifted convention, -/// NOT retail's wire convention. must -/// use the CORRECT scalar bridge from acdream yaw (yaw=0 faces +X, per +/// The scalar-convention trap (V0-pins §P5 correction): +/// must use the correct scalar bridge +/// from acdream yaw (yaw=0 faces +X, per /// PlayerMovementController.cs:1022-1025): heading = (90 - /// yawDeg) mod 360 — NOT 180 - yawDeg. /// diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs index 02ee10ea..1b712508 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs @@ -164,6 +164,27 @@ public sealed class MovementManagerTests Assert.Equal(0, calls[0]); } + [Fact] + public void PerformMovement_ActivatesBeforeDispatch_EvenWhenTypeIsInvalid() + { + var (mm, _, _) = MakeFacade(); + int activations = 0; + mm.ActivatePhysicsObject = () => activations++; + + Assert.Equal(WeenieError.None, + mm.PerformMovement(new MovementStruct + { + Type = MovementType.StopCompletely, + })); + Assert.Equal(WeenieError.GeneralMovementFailure, + mm.PerformMovement(new MovementStruct + { + Type = (MovementType)99, + })); + + Assert.Equal(2, activations); + } + [Fact] public void PerformMovement_MoveToType_WithoutFactory_Fails0x47() { diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index ef0f3292..d4b59bff 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -21,9 +21,9 @@ namespace AcDream.Core.Tests.Physics.Motion; // the scalar) and drains pending_motions synthetically, so the two legs where // the live #170 residual actually lives have ZERO coverage: // -// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink. -// TurnApplied → ObservedOmega → per-tick quaternion integration → -// MoveToMath.GetHeading → HandleTurnToHeading's HeadingGreater test; +// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink → +// DAT MotionData.Omega → CSequence's complete root Frame → +// Frame::combine → HandleTurnToHeading's HeadingGreater test; // 2. the REAL drain: MotionTableManager pending_animations countdown fed by // CSequence AnimDone hooks (link-anim completions), popping // CMotionInterp.pending_motions via the MotionDoneTarget seam. @@ -31,11 +31,9 @@ namespace AcDream.Core.Tests.Physics.Motion; // This harness wires a real MotionInterpreter + AnimationSequencer + // MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's // EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in -// GameWindow.TickAnimations' per-entity order for the grounded branch=MOVETO -// path (GameWindow.cs:9697 HandleTargetting → 9994 TickRemoteMoveTo → -// 10024 get_state_velocity refresh → 10050 manual omega integration → -// 10247 Sequencer.Advance → 10306 AnimationDone per AnimDoneHook → -// 10309 Manager.UseTime). Wire events (aggro stance UM, mt-6 arms, attack +// the retail per-object order: CSequence update → PositionManager adjust → +// complete Frame combine → hook processing → Target/Movement/PartArray/ +// Position manager tail. Wire events (aggro stance UM, mt-6 arms, attack // UMs) replay the exact live sequence captured in launch-drainq.log // (2026-07-04, guid 0x80000BE5, Mite Scamp chasing the fleeing player). // @@ -118,9 +116,6 @@ internal sealed class RemoteChaseHarness private readonly CreatureHost _creatureHost; private readonly TargetHost _playerHost; - /// GameWindow's RemoteMotion.ObservedOmega twin. - public Vector3 ObservedOmega; - /// Scripted player (chase target) world position. public Vector3 PlayerPos; public Vector3 PlayerVelocity; @@ -162,17 +157,7 @@ internal sealed class RemoteChaseHarness }; // ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ── - Sink = new MotionTableDispatchSink(Seq) - { - TurnApplied = (turnMotion, turnSpeed) => - { - float signed = (turnMotion & 0xFFu) == 0x0E - ? -MathF.Abs(turnSpeed) - : turnSpeed; - ObservedOmega = new Vector3(0f, 0f, -(MathF.PI / 2f) * signed); - }, - TurnStopped = () => ObservedOmega = Vector3.Zero, - }; + Sink = new MotionTableDispatchSink(Seq); Interp.DefaultSink = Sink; // #174: production binds the seam to Manager.HandleEnterWorld // (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) — @@ -425,64 +410,52 @@ internal sealed class RemoteChaseHarness // Player (chase target) moves per its scripted velocity. PlayerPos += PlayerVelocity * Dt; - // 1. Voyeur delivery (player host HandleTargetting → this entity's - // HandleUpdateTarget; GameWindow.cs:8094 runs before the per-remote - // loop, 9697 in-loop). - if (_targetArmed && Now - _lastDeliveryTime >= _quantum) - DeliverTargetInfo(); - - // 2. MovementManager drive (TickRemoteMoveTo — the R5-V5 facade - // relay, MovementManager::UseTime 0x005242f0). - Movement.UseTime(); - - // 3. get_state_velocity → body velocity (the d2ccc80e refresh, - // GameWindow.cs:10024). - if (Body.OnWalkable) - Body.set_local_velocity(Interp.get_state_velocity(), false); - - // 4. Manual omega integration (GameWindow.cs:10050-10058 verbatim). - if (ObservedOmega.LengthSquared() > 1e-8f) + // CPartArray::Update emits one complete local Frame: authored PosFrames + // plus MotionData velocity/omega (CSequence::apply_physics 0x00524AB0). + var rootFrame = new Frame { - float omegaMag = ObservedOmega.Length(); - var axis = ObservedOmega / omegaMag; - float angle = omegaMag * Dt; - var deltaRot = Quaternion.CreateFromAxisAngle(axis, angle); - Body.Orientation = Quaternion.Normalize( - Quaternion.Multiply(Body.Orientation, deltaRot)); - } + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }; + Seq.Advance(Dt, rootFrame); - // 5. R5-V3 (#171): the sticky steer — GameWindow's legacy-branch slot - // (retail UpdatePositionInternal PositionManager::adjust_offset - // @0x00512d0e, composed BEFORE the velocity integration). Origin is - // mover-local; the rotation carries a RELATIVE heading (identity = - // untouched = no turn). - var pmDelta = new MotionDeltaFrame(); + // PositionManager receives that same Frame and may replace its motion + // (sticky/interpolation) before Frame::combine applies translation with + // the OLD orientation, then post-multiplies the relative orientation. + var pmDelta = new MotionDeltaFrame + { + Origin = rootFrame.Origin, + Orientation = rootFrame.Orientation, + }; Pm.AdjustOffset(pmDelta, Dt); if (pmDelta.Origin != Vector3.Zero) Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation); if (!pmDelta.Orientation.IsIdentity) - Body.Orientation = MoveToMath.SetHeading( - Body.Orientation, - MoveToMath.GetHeading(Body.Orientation) + pmDelta.GetHeading()); + Body.Orientation = Quaternion.Normalize( + Body.Orientation * pmDelta.Orientation); - // 5b. Position integration (UpdatePhysicsInternal, simplified: grounded, - // no gravity participation for this scenario). - Body.Position += Body.Velocity * Dt; + // The fixture has no gravity or independent physical velocity, but run + // the same physics primitive so a future fixture cannot accidentally + // bypass the retail slot. + Body.calc_acceleration(); + Body.UpdatePhysicsInternal(Dt); - // 5c. R5-V3: PositionManager::UseTime (retail UpdateObjectInternal - // tail @0x005159b3) — the sticky 1 s lease watchdog. - Pm.UseTime(); - - // 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone - // hooks into the manager countdown, zero-tick sweep. - Seq.Advance(Dt); + // process_hooks precedes the manager tail. AnimationDone decrements the + // exact pending-animation node that owns the transition. var hooks = Seq.ConsumePendingHooks(); for (int i = 0; i < hooks.Count; i++) { if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook) Seq.Manager.AnimationDone(success: true); } + + // UpdateObjectInternal manager tail: Target → Movement → PartArray → + // Position. A movement selected here contributes to next tick's Frame. + if (_targetArmed && Now - _lastDeliveryTime >= _quantum) + DeliverTargetInfo(); + Movement.UseTime(); Seq.Manager.UseTime(); + Pm.UseTime(); // ── Observables ── uint substate = Seq.Manager.State.Substate; @@ -509,7 +482,7 @@ internal sealed class RemoteChaseHarness public void Snapshot(string tag) { string line = FormattableString.Invariant( - $"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={ObservedOmega.Z,6:F3}"); + $"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={Seq.CurrentOmega.Z,6:F3}"); Trace.Add(line); _log?.WriteLine(line); } @@ -627,7 +600,11 @@ internal sealed class RemoteChaseHarness AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink)); // Global (unstyled) TurnRight modifier — physics-only in Branch 4. - mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(TurnAnim); + // The signed DAT omega is the rotation source, matching retail's + // Humanoid motion table; no command-derived side channel exists. + mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd( + TurnAnim, + omega: new Vector3(0f, 0f, -1.5f)); return (setup, mt, loader); } @@ -646,7 +623,7 @@ public sealed class RemoteChaseEndToEndHarnessTests /// The core chase cycle: aggro stance change, one mt-6 arm at a target /// 90° off the creature's facing, 15 m away, stationary. Retail bar: the /// stance links play out (~1 s), the chase turn starts, completes at the - /// turn rate (90° at π/2·2.08 rad/s ≈ 0.5 s), and BeginMoveForward + /// authored turn rate (90° at 1.5·2.08 rad/s ≈ 0.5 s), and BeginMoveForward /// installs the forward cycle. Total budget: 4 s of ticks is generous. /// [Theory] @@ -692,7 +669,7 @@ public sealed class RemoteChaseEndToEndHarnessTests Assert.True(installTick >= 0, $"forward cycle never installed within 6 s of the arm (bearing {bearingDeg}°); " + $"final: mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " + - $"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.ObservedOmega.Z:F3}"); + $"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.Seq.CurrentOmega.Z:F3}"); Assert.True(installTick <= Seconds(4f), $"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " + "retail installs within the turn duration (~1-2 s)"); diff --git a/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs b/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs index 7df939fc..d9082b49 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs @@ -139,7 +139,7 @@ public sealed class MotionVelocityPipelineTests Assert.True(v.X < 0f, "strafe-left velocity must be negative"); } - // ── turn (drives the local Yaw omega: base π/2 × TurnSpeed) ─────────────── + // ── turn normalization (the DAT MotionData supplies production omega) ──── [Fact] public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor() diff --git a/tests/AcDream.Core.Tests/Physics/ProjectilePhysicsStepperTests.cs b/tests/AcDream.Core.Tests/Physics/ProjectilePhysicsStepperTests.cs index e5dd47c5..df7fa597 100644 --- a/tests/AcDream.Core.Tests/Physics/ProjectilePhysicsStepperTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ProjectilePhysicsStepperTests.cs @@ -104,6 +104,40 @@ public sealed class ProjectilePhysicsStepperTests Assert.Equal(50f, body.CachedVelocity.Y, 3); } + [Fact] + public void SplitQuantum_HoldsBeginFrameAcrossHookSlotThenCommitsCandidate() + { + var (engine, _) = BuildEmptyEngine(); + var body = MakeBody( + new Vector3(12f, 10f, 2f), + new Vector3(10f, 0f, 0f)); + var stepper = new ProjectilePhysicsStepper(engine); + Vector3 begin = body.Position; + + ProjectileQuantumPreparation preparation = stepper.BeginQuantum( + body, + 0.1f, + Cell, + ArrowSphere); + + Assert.True(preparation.Simulated); + Assert.True(preparation.RequiresTransition); + Assert.Equal(begin, body.Position); + Assert.Equal(begin + Vector3.UnitX, preparation.CandidatePosition); + + ProjectileAdvanceResult result = stepper.CompleteQuantum( + body, + preparation, + ArrowSphere, + ProjectileId); + + Assert.True(result.Simulated); + Assert.InRange( + Vector3.Distance(begin + Vector3.UnitX, body.Position), + 0f, + 0.00001f); + } + [Fact] public void Advance_GravityUsesFinalPhysicsState_NotParsedAcceleration() { diff --git a/tests/AcDream.Core.Tests/Physics/RetailObjectActivityGateTests.cs b/tests/AcDream.Core.Tests/Physics/RetailObjectActivityGateTests.cs new file mode 100644 index 00000000..b2fc860c --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailObjectActivityGateTests.cs @@ -0,0 +1,150 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +public sealed class RetailObjectActivityGateTests +{ + [Fact] + public void FarPartArrayObject_ConsumesInactiveClockAndRebasesWhenNearAgain() + { + var clock = new RetailObjectQuantumClock(); + var body = new PhysicsBody + { + TransientState = TransientStateFlags.Active, + }; + Assert.Equal(0, clock.Advance(0.02).Count); + + Assert.Equal( + RetailObjectActivityResult.Inactive, + RetailObjectActivityGate.Evaluate( + clock, + body, + lifecycleEligible: true, + hasPartArray: true, + isStatic: false, + objectPosition: new Vector3(96.01f, 0f, 0f), + playerPosition: Vector3.Zero, + elapsedSeconds: 0.02)); + Assert.False(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + + Assert.Equal( + RetailObjectActivityResult.Reactivated, + RetailObjectActivityGate.Evaluate( + clock, + body, + lifecycleEligible: true, + hasPartArray: true, + isStatic: false, + objectPosition: new Vector3(95.99f, 0f, 0f), + playerPosition: Vector3.Zero, + elapsedSeconds: 0.02)); + Assert.True(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Active)); + } + + [Fact] + public void FrozenInterval_IsDiscardedByInactiveToActiveRebase() + { + var clock = new RetailObjectQuantumClock(); + Assert.Equal(0, clock.Advance(0.02).Count); + + Assert.Equal( + RetailObjectActivityResult.Suspended, + RetailObjectActivityGate.Evaluate( + clock, + body: null, + lifecycleEligible: false, + hasPartArray: true, + isStatic: false, + objectPosition: Vector3.Zero, + playerPosition: Vector3.Zero, + elapsedSeconds: 0.5)); + Assert.Equal( + RetailObjectActivityResult.Reactivated, + RetailObjectActivityGate.Evaluate( + clock, + body: null, + lifecycleEligible: true, + hasPartArray: true, + isStatic: false, + objectPosition: Vector3.Zero, + playerPosition: Vector3.Zero, + elapsedSeconds: 0.01)); + + Assert.Equal(0.0, clock.PendingSeconds, 8); + Assert.Equal(0, clock.Advance(0.02).Count); + } + + [Fact] + public void PartArrayLessObject_RemainsActiveBeyondDistanceGate() + { + var clock = new RetailObjectQuantumClock(); + + Assert.Equal( + RetailObjectActivityResult.Active, + RetailObjectActivityGate.Evaluate( + clock, + body: null, + lifecycleEligible: true, + hasPartArray: false, + isStatic: false, + objectPosition: new Vector3(500f, 0f, 0f), + playerPosition: Vector3.Zero, + elapsedSeconds: 0.02)); + Assert.True(clock.IsActive); + } + + [Fact] + public void MissingPlayer_PreservesPriorInactiveStateWithoutReactivation() + { + var clock = new RetailObjectQuantumClock(); + clock.Deactivate(); + + Assert.Equal( + RetailObjectActivityResult.Inactive, + RetailObjectActivityGate.Evaluate( + clock, + body: null, + lifecycleEligible: true, + hasPartArray: true, + isStatic: false, + objectPosition: Vector3.Zero, + playerPosition: null, + elapsedSeconds: 0.04)); + + Assert.False(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + } + + [Fact] + public void NearStaticObject_DoesNotReactivateOrdinaryObjectPath() + { + var clock = new RetailObjectQuantumClock(); + clock.Deactivate(); + var body = new PhysicsBody + { + State = PhysicsStateFlags.Static, + TransientState = TransientStateFlags.None, + }; + + Assert.Equal( + RetailObjectActivityResult.Inactive, + RetailObjectActivityGate.Evaluate( + clock, + body, + lifecycleEligible: true, + hasPartArray: true, + isStatic: true, + objectPosition: Vector3.Zero, + playerPosition: Vector3.Zero, + elapsedSeconds: 0.04)); + + Assert.False(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/RetailObjectQuantumClockTests.cs b/tests/AcDream.Core.Tests/Physics/RetailObjectQuantumClockTests.cs new file mode 100644 index 00000000..9a768be9 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailObjectQuantumClockTests.cs @@ -0,0 +1,110 @@ +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +public sealed class RetailObjectQuantumClockTests +{ + [Fact] + public void Advance_SubMinimumTime_IsRetainedUntilStrictThresholdIsCrossed() + { + var clock = new RetailObjectQuantumClock(); + + Assert.Equal(0, clock.Advance(PhysicsBody.MinQuantum * 0.5).Count); + Assert.Equal(0, clock.Advance(PhysicsBody.MinQuantum * 0.5).Count); + RetailObjectQuantumBatch batch = clock.Advance(0.00001); + + Assert.Equal(1, batch.Count); + Assert.True(batch.GetQuantum(0) > PhysicsBody.MinQuantum); + Assert.Equal(0.0, clock.PendingSeconds, 8); + } + + [Fact] + public void Advance_ExactFourTenths_EqualsTwoRetailMaxQuanta() + { + var clock = new RetailObjectQuantumClock(); + + RetailObjectQuantumBatch batch = clock.Advance(0.4); + + Assert.Equal(2, batch.Count); + Assert.Equal(PhysicsBody.MaxQuantum, batch.GetQuantum(0)); + Assert.Equal(PhysicsBody.MaxQuantum, batch.GetQuantum(1), 6); + } + + [Fact] + public void Advance_HugePlusEpsilon_DiscardsWithoutObjectTick() + { + var clock = new RetailObjectQuantumClock(); + + RetailObjectQuantumBatch batch = clock.Advance( + PhysicsBody.HugeQuantum + 0.00001); + + Assert.True(batch.Discarded); + Assert.Equal(0, batch.Count); + Assert.Equal(0.0, clock.PendingSeconds); + } + + [Fact] + public void Advance_ExactHugeQuantum_IsSubdividedNotDiscarded() + { + var clock = new RetailObjectQuantumClock(); + + RetailObjectQuantumBatch batch = clock.Advance(PhysicsBody.HugeQuantum); + + Assert.False(batch.Discarded); + Assert.Equal(10, batch.Count); + for (int i = 0; i < batch.Count; i++) + Assert.Equal(PhysicsBody.MaxQuantum, batch.GetQuantum(i), 6); + } + + [Fact] + public void Advance_MicroQuantum_IsConsumedRatherThanAccumulated() + { + var clock = new RetailObjectQuantumClock(); + + for (int i = 0; i < 100; i++) + Assert.Equal(0, clock.Advance(0.0001).Count); + + Assert.Equal(0.0, clock.PendingSeconds); + } + + [Fact] + public void DeactivateThenActivate_RebasesOnceAndDoesNotCatchUp() + { + var clock = new RetailObjectQuantumClock(); + Assert.Equal(0, clock.Advance(0.02).Count); + + clock.Deactivate(); + Assert.False(clock.IsActive); + Assert.Equal(0.02, clock.PendingSeconds, 8); + Assert.True(clock.Activate()); + Assert.True(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + Assert.False(clock.Activate()); + } + + [Fact] + public void ResetForEnterWorld_DiscardsFragmentAndActivatesImmediately() + { + var clock = new RetailObjectQuantumClock(); + Assert.Equal(0, clock.Advance(0.02).Count); + + clock.ResetForEnterWorld(); + + Assert.True(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + Assert.False(clock.Activate()); + } + + [Fact] + public void ResetForEnterWorld_StaticObjectRebasesButRetainsInactiveState() + { + var clock = new RetailObjectQuantumClock(); + Assert.Equal(0, clock.Advance(0.02).Count); + clock.Deactivate(); + + clock.ResetForEnterWorld(isStatic: true); + + Assert.False(clock.IsActive); + Assert.Equal(0.0, clock.PendingSeconds, 8); + } +}