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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-20 09:10:31 +02:00
parent 31a0889f08
commit f961d70023
77 changed files with 12513 additions and 1871 deletions

View file

@ -111,8 +111,12 @@ movement queries.
**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent, **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 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 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 foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete;
portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6/TS-42, Track MP0. 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. Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below.
For canonical state, read in this order: For canonical state, read in this order:

View file

@ -109,8 +109,12 @@ movement queries.
**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent, **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 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 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 foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete;
portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6/TS-42, Track MP0. 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. Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below.
For canonical state, read in this order: For canonical state, read in this order:

View file

@ -207,16 +207,22 @@ src/
Physics/ Physics/
ProjectileController.cs -> live-record projectile orchestration/corrections ProjectileController.cs -> live-record projectile orchestration/corrections
RemotePhysicsUpdater.cs -> ordinary/Hidden remote narrow-tick integration 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 RemoteTeleportController.cs -> incarnation-scoped loaded/pending placement owner
RemoteTeleportHook.cs -> ordered retail teleport teardown seam RemoteTeleportHook.cs -> ordered retail teleport teardown seam
RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit
World/ World/
LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership
RetailInboundEventDispatcher.cs -> update-thread packet/frame FIFO barrier
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier
Rendering/ Rendering/
GameWindow.cs -> still owns too much runtime wiring 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 TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path
Wb/WbDrawDispatcher.cs -> ordinary live/static entity draw dispatch Wb/WbDrawDispatcher.cs -> ordinary live/static entity draw dispatch
Wb/EnvCellRenderer.cs -> indoor cell-shell draw path Wb/EnvCellRenderer.cs -> indoor cell-shell draw path
@ -285,6 +291,67 @@ What exists and is active:
owns the frame; owns the frame;
delete, generation replacement, pickup/parent leave-world, and session reset delete, generation replacement, pickup/parent leave-world, and session reset
use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map. 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 - `RemoteTeleportController` owns the placement half of a fresh remote
teleport after `RemoteTeleportHook` has torn down movement/target state. It teleport after `RemoteTeleportHook` has torn down movement/target state. It
collision-seats loaded destinations through `RemoteTeleportPlacement`; an collision-seats loaded destinations through `RemoteTeleportPlacement`; an

View file

@ -165,6 +165,9 @@ src/AcDream.App/
├── Rendering/ ├── Rendering/
│ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator │ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator
│ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI) │ ├── 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) │ ├── TerrainModernRenderer.cs # (already exists)
│ ├── TextureCache.cs # (already exists) │ ├── TextureCache.cs # (already exists)
│ ├── ParticleRenderer.cs # (already exists) │ ├── ParticleRenderer.cs # (already exists)
@ -176,12 +179,16 @@ src/AcDream.App/
├── Physics/ ├── Physics/
│ ├── ProjectileController.cs # canonical live-record projectile orchestration │ ├── ProjectileController.cs # canonical live-record projectile orchestration
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration │ ├── 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 │ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
│ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions │ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions
│ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit │ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit
├── World/ ├── World/
│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots │ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation │ ├── 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 │ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects
│ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain │ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations │ └── 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 All other non-Parent packet
families still need the future general queue tracked by divergence AD-32. 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`, Remote teleport placement is bounded in `Physics/RemoteTeleportController`,
not `GameWindow`: it retains at most one pending request per materialized not `GameWindow`: it retains at most one pending request per materialized
incarnation, scopes it by the live generation and accepted PositionSequence, incarnation, scopes it by the live generation and accepted PositionSequence,

View file

@ -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 | | # | 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 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 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-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-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-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-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-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-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-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-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) | | 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-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-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-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 `|BodyLastShadowSyncPos| > 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-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-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-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` | | 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-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | | AP-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 | | # | 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-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-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-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-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-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-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-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-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 |
--- ---

View file

@ -872,9 +872,52 @@ diagnostic scaffolding, not yet the final collision system.
183-case/funnel/moveto/chase/sticky suites unmodified. Arc close-out: 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 `docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`. Open follow-ons
carried: #167 (ConstraintManager arming). TS-42's per-tick completion/ carried: #167 (ConstraintManager arming). TS-42's per-tick completion/
manager order retired in the first R6 cutover on 2026-07-19; complete-frame manager order retired in the first R6 cutover on 2026-07-19. The second R6
plumbing, PositionManager interpolation, quantum ownership, and side-channel cutover now carries complete CSequence Frames (translation + DAT omega),
deletion remain R6 scope. 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).** - **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).**
ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL
remote entities (players, NPCs, monsters — one funnel, user-approved): remote entities (players, NPCs, monsters — one funnel, user-approved):

View file

@ -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 presentation have passed their single-client visual gates. The corrective bounded
render/resource-lifetime integration passed its seven-destination connected render/resource-lifetime integration passed its seven-destination connected
stress gate without a crash or cumulative collapse. The remaining M3 visual 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: Carried:
#145-residual, #116 slide-response, the remaining R6 object-pipeline cutover #145-residual, #116 slide-response, the remaining R6 ownership cleanup plus
(TS-42 manager order retired 2026-07-19), and Track MP0. 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.
--- ---

View file

@ -4,6 +4,15 @@
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp). **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). **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`) ## 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` ## 4. `InterpolationManager::adjust_offset` @ `0x00555d30`
Signature: `void adjust_offset(InterpolationManager *this, Frame *arg2, double arg3)` 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). - `arg3` = frame `dt` in seconds (double).
### Critical answer: **arg2 is MUTATED IN PLACE.** Not a delta-return. ### 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); 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 ```c
00512d22 Frame::combine(arg3 /*world out*/, &this->m_position.frame, &var_40 /*= arg2*/); 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: ### Step-by-step retail flow:
@ -238,7 +254,7 @@ if (this->keep_heading != 0) {
} }
// ---- Output ---- // ---- Output ----
Frame::operator=(arg2, &delta_frame); // OUT: arg2 = translation-only Frame Frame::operator=(arg2, &delta_frame); // OUT: complete relative Frame
``` ```
### NOTE on `progress_quantum` ### 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. | | **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). | | 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. | | `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 ### Concrete actionable changes

View file

@ -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** | | 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) | | 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** | | 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 | | 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 | **`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 | | 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` 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 | | 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** | | 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 | | 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 | | 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) | | 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` | | 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 | | 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)`) | | `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") | | `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`) | | `AnimData` speed scaling: only framerate × speed, low/high pass through | `operator* 0x00525d00` (invoked from `add_motion` @0x0052255b) | `LoadAnimNode` (`AnimData.Framerate * speedMod`) |

View file

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

View file

@ -11,15 +11,11 @@ namespace AcDream.App.Input;
/// </summary> /// </summary>
public sealed class LocalPlayerOutboundController public sealed class LocalPlayerOutboundController
{ {
private readonly Func<float, Quaternion> _toWireRotation;
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic; private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
public LocalPlayerOutboundController( public LocalPlayerOutboundController(
Func<float, Quaternion> toWireRotation,
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic) Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
{ {
_toWireRotation = toWireRotation
?? throw new ArgumentNullException(nameof(toWireRotation));
_diagnostic = diagnostic _diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic)); ?? throw new ArgumentNullException(nameof(diagnostic));
} }
@ -38,14 +34,13 @@ public sealed class LocalPlayerOutboundController
if (session is null || hidden) if (session is null || hidden)
return; return;
Quaternion wireRotation = _toWireRotation(controller.Yaw); if (!controller.TryGetOutboundPosition(out Position outboundPosition))
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
{ {
return; return;
} }
uint wireCellId = outboundPosition.ObjCellId;
Vector3 wirePosition = outboundPosition.Frame.Origin;
Quaternion wireRotation = outboundPosition.Frame.Orientation;
if (movement.ShouldSendMovementEvent) if (movement.ShouldSendMovementEvent)
TrySendMovement(session, controller, movement); TrySendMovement(session, controller, movement);
@ -82,16 +77,14 @@ public sealed class LocalPlayerOutboundController
if (session is null || hidden) if (session is null || hidden)
return; return;
Quaternion wireRotation = _toWireRotation(controller.Yaw); if (!controller.TryGetOutboundPosition(out Position position))
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
{ {
return; 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( if (!controller.ShouldSendPositionEvent(
position, position,
controller.ContactPlane, controller.ContactPlane,
@ -136,14 +129,13 @@ public sealed class LocalPlayerOutboundController
if (session is null || controller is null) if (session is null || controller is null)
return false; return false;
Quaternion wireRotation = _toWireRotation(controller.Yaw); if (!controller.TryGetOutboundPosition(out Position outboundPosition))
if (!controller.TryGetOutboundPosition(
wireRotation,
out uint wireCellId,
out Vector3 wirePosition))
{ {
return false; return false;
} }
uint wireCellId = outboundPosition.ObjCellId;
Vector3 wirePosition = outboundPosition.Frame.Origin;
Quaternion wireRotation = outboundPosition.Frame.Orientation;
byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0; byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
RawMotionState rawMotionState = BuildRawMotionState(movement); RawMotionState rawMotionState = BuildRawMotionState(movement);

View file

@ -13,19 +13,27 @@ public sealed class LocalPlayerProjectionController
private readonly Func<int> _liveCenterY; private readonly Func<int> _liveCenterY;
private readonly Action<WorldEntity, uint> _syncShadow; private readonly Action<WorldEntity, uint> _syncShadow;
private readonly Action<uint, uint> _rebucket; private readonly Action<uint, uint> _rebucket;
private readonly Func<WorldEntity, bool> _isCurrentVisibleProjection;
private readonly Action<WorldEntity> _suspendShadow;
public LocalPlayerProjectionController( public LocalPlayerProjectionController(
Func<WorldEntity?> resolveEntity, Func<WorldEntity?> resolveEntity,
Func<int> liveCenterX, Func<int> liveCenterX,
Func<int> liveCenterY, Func<int> liveCenterY,
Action<WorldEntity, uint> syncShadow, Action<WorldEntity, uint> syncShadow,
Action<uint, uint> rebucket) Action<uint, uint> rebucket,
Func<WorldEntity, bool> isCurrentVisibleProjection,
Action<WorldEntity> suspendShadow)
{ {
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity)); _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
_liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX)); _liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX));
_liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY)); _liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY));
_syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow)); _syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow));
_rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket)); _rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket));
_isCurrentVisibleProjection = isCurrentVisibleProjection
?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection));
_suspendShadow = suspendShadow
?? throw new ArgumentNullException(nameof(suspendShadow));
} }
public void Project( public void Project(
@ -39,11 +47,10 @@ public sealed class LocalPlayerProjectionController
entity.SetPosition(movement.RenderPosition); entity.SetPosition(movement.RenderPosition);
entity.ParentCellId = movement.CellId; entity.ParentCellId = movement.CellId;
entity.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( // Retail's root is a complete Frame. Project the authoritative body
System.Numerics.Vector3.UnitZ, // quaternion directly so pitch/roll from a server correction or DAT
controller.Yaw - MathF.PI / 2f); // root frame cannot be flattened by the presentation layer.
if (!hidden) entity.Rotation = controller.BodyOrientation;
_syncShadow(entity, movement.CellId);
uint currentLandblock; uint currentLandblock;
if (movement.CellId != 0 && (movement.CellId & 0xFFFFu) >= 0x0100u) 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 // The teleport owner alone projects the destination while the local
// controller deliberately retains its frozen source cell. // controller deliberately retains its frozen source cell.
if (controller.State != PlayerState.PortalSpace) if (controller.State == PlayerState.PortalSpace)
_rebucket(entity.ServerGuid, currentLandblock); 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);
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -21,18 +21,33 @@ public sealed class RetailLocalPlayerFrameController
private readonly Action<PlayerMovementController, MovementResult, bool> _project; private readonly Action<PlayerMovementController, MovementResult, bool> _project;
private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork; private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork;
private readonly Action<PlayerMovementController, bool> _sendPostNetwork; private readonly Action<PlayerMovementController, bool> _sendPostNetwork;
private readonly Func<AcDream.Core.Physics.RetailObjectClockDisposition>
_objectClockDisposition;
private AdvancedFrame? _advancedFrame; private AdvancedFrame? _advancedFrame;
private readonly record struct AdvancedFrame( private readonly record struct AdvancedFrame(
PlayerMovementController Controller, PlayerMovementController Controller,
MovementResult Movement); MovementResult Movement,
bool ObjectAdvanced,
bool Hidden,
bool ObjectQuantumAdvanced);
public readonly record struct PresentationFrame( public readonly record struct PresentationFrame(
MovementResult Movement, MovementResult Movement,
bool Hidden, bool Hidden,
bool AdvancedBeforeNetwork); bool AdvancedBeforeNetwork);
/// <summary>
/// Whether the pre-network Hidden player update admitted a complete object
/// quantum whose retained CPartArray pose must be sampled this frame.
/// </summary>
internal bool HiddenPartPoseDirty => _advancedFrame is
{
Hidden: true,
ObjectQuantumAdvanced: true,
};
public RetailLocalPlayerFrameController( public RetailLocalPlayerFrameController(
Func<bool> canPresentPlayer, Func<bool> canPresentPlayer,
Func<PlayerMovementController?> getController, Func<PlayerMovementController?> getController,
@ -42,7 +57,9 @@ public sealed class RetailLocalPlayerFrameController
Func<bool> isHidden, Func<bool> isHidden,
Action<PlayerMovementController, MovementResult, bool> project, Action<PlayerMovementController, MovementResult, bool> project,
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork, Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
Action<PlayerMovementController, bool> sendPostNetwork) Action<PlayerMovementController, bool> sendPostNetwork,
Func<AcDream.Core.Physics.RetailObjectClockDisposition>?
objectClockDisposition = null)
{ {
_canPresentPlayer = canPresentPlayer _canPresentPlayer = canPresentPlayer
?? throw new ArgumentNullException(nameof(canPresentPlayer)); ?? throw new ArgumentNullException(nameof(canPresentPlayer));
@ -62,6 +79,8 @@ public sealed class RetailLocalPlayerFrameController
?? throw new ArgumentNullException(nameof(sendPreNetwork)); ?? throw new ArgumentNullException(nameof(sendPreNetwork));
_sendPostNetwork = sendPostNetwork _sendPostNetwork = sendPostNetwork
?? throw new ArgumentNullException(nameof(sendPostNetwork)); ?? throw new ArgumentNullException(nameof(sendPostNetwork));
_objectClockDisposition = objectClockDisposition
?? (() => AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
} }
/// <summary> /// <summary>
@ -75,16 +94,54 @@ public sealed class RetailLocalPlayerFrameController
if (!_canPresentPlayer() || controller is null) if (!_canPresentPlayer() || controller is null)
return; 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(); controller.LocalEntityId = _resolveLocalEntityId();
bool hidden = _isHidden(); 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 MovementResult movement = hidden
? controller.TickHidden(deltaSeconds, _handleTargeting) ? controller.TickHidden(deltaSeconds, _handleTargeting)
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting); : controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
_project(controller, movement, hidden); _project(controller, movement, hidden);
_sendPreNetwork(controller, movement, hidden); _sendPreNetwork(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(controller, movement); _advancedFrame = new AdvancedFrame(
controller,
movement,
ObjectAdvanced: true,
Hidden: hidden,
ObjectQuantumAdvanced: controller.AdvancedObjectQuantumLastTick);
} }
/// <summary> /// <summary>
@ -106,7 +163,15 @@ public sealed class RetailLocalPlayerFrameController
advanced.Movement, advanced.Movement,
controller); controller);
_project(controller, movement, hidden); _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); _sendPostNetwork(controller, hidden);
@ -131,7 +196,10 @@ public sealed class RetailLocalPlayerFrameController
MovementResult movement = RefreshSpatialFields( MovementResult movement = RefreshSpatialFields(
advanced.Movement, advanced.Movement,
controller); controller);
frame = new PresentationFrame(movement, hidden, AdvancedBeforeNetwork: true); frame = new PresentationFrame(
movement,
hidden,
AdvancedBeforeNetwork: advanced.ObjectAdvanced);
return true; return true;
} }

View file

@ -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;
/// <summary>
/// Owns the body-backed, manager-less branch of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (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.
/// </summary>
internal sealed class LiveEntityOrdinaryPhysicsUpdater
{
private readonly PhysicsEngine _physics;
private readonly Func<uint, WorldEntity, (float Radius, float Height)>
_getSetupCylinder;
public LiveEntityOrdinaryPhysicsUpdater(
PhysicsEngine physics,
Func<uint, WorldEntity, (float Radius, float Height)> 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<uint, AnimationSequencer> 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;
}

View file

@ -0,0 +1,56 @@
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Physics;
/// <summary>
/// Incarnation/residency gate for authoritative ordinary-remote collision
/// publication after a canonical rebucket callback.
/// </summary>
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);
}

View file

@ -5,6 +5,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
namespace AcDream.App.Physics; namespace AcDream.App.Physics;
@ -30,26 +31,34 @@ internal sealed class ProjectileController
internal Runtime( internal Runtime(
ushort generation, ushort generation,
PhysicsBody body, PhysicsBody body,
ProjectileCollisionSphere collisionSphere, ProjectileCollisionSphere collisionSphere)
bool hasPartArray)
{ {
Generation = generation; Generation = generation;
Body = body; Body = body;
CollisionSphere = collisionSphere; CollisionSphere = collisionSphere;
HasPartArray = hasPartArray;
} }
internal ushort Generation { get; } internal ushort Generation { get; }
public PhysicsBody Body { get; } public PhysicsBody Body { get; }
internal ProjectileCollisionSphere CollisionSphere { 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 LiveEntityRuntime _liveEntities;
private readonly ProjectilePhysicsStepper _stepper; private readonly ProjectilePhysicsStepper _stepper;
private readonly ShadowObjectRegistry _shadows; private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, Setup?> _setupResolver; private readonly Func<uint, Setup?> _setupResolver;
private readonly Action<WorldEntity> _publishRootPose; private readonly Action<WorldEntity> _publishRootPose;
private readonly Func<(int X, int Y)> _liveCenterProvider;
private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new(); private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new();
private double _lastFiniteGameTime; private double _lastFiniteGameTime;
@ -57,7 +66,8 @@ internal sealed class ProjectileController
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,
PhysicsEngine physicsEngine, PhysicsEngine physicsEngine,
Func<uint, Setup?>? setupResolver = null, Func<uint, Setup?>? setupResolver = null,
Action<WorldEntity>? publishRootPose = null) Action<WorldEntity>? publishRootPose = null,
Func<(int X, int Y)>? liveCenterProvider = null)
{ {
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
ArgumentNullException.ThrowIfNull(physicsEngine); ArgumentNullException.ThrowIfNull(physicsEngine);
@ -65,6 +75,7 @@ internal sealed class ProjectileController
_shadows = physicsEngine.ShadowObjects; _shadows = physicsEngine.ShadowObjects;
_setupResolver = setupResolver ?? (_ => null); _setupResolver = setupResolver ?? (_ => null);
_publishRootPose = publishRootPose ?? (_ => { }); _publishRootPose = publishRootPose ?? (_ => { });
_liveCenterProvider = liveCenterProvider ?? (() => (0, 0));
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
} }
@ -126,7 +137,9 @@ internal sealed class ProjectileController
ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(setup); 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; return false;
if (record.ProjectileRuntime is Runtime retained) if (record.ProjectileRuntime is Runtime retained)
@ -158,12 +171,13 @@ internal sealed class ProjectileController
_lastFiniteGameTime = currentTime; _lastFiniteGameTime = currentTime;
PhysicsBody body; PhysicsBody body;
if (record.RemoteMotionRuntime is { } remote) if (record.PhysicsBody is { } sharedBody)
{ {
// Retail owns one CPhysicsObj. If a non-missile incarnation // Retail owns one CPhysicsObj. If a non-missile incarnation
// already created its MovementManager, classification adopts that // already created its MovementManager or entered the Physics-Static
// same body instead of replacing it. // animation workset, classification adopts that same body instead
body = remote.Body; // of replacing it or replaying CreateObject vectors.
body = sharedBody;
uint currentCellId = record.FullCellId; uint currentCellId = record.FullCellId;
Vector3 currentCellLocal = CellLocalFromWorld( Vector3 currentCellLocal = CellLocalFromWorld(
body.Position, body.Position,
@ -219,38 +233,69 @@ internal sealed class ProjectileController
$"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector."); $"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector.");
return false; return false;
} }
body = new PhysicsBody // Claim the incarnation's one CPhysicsObj before Rebucket can
{ // publish visibility callbacks. A callback that also needs a body
State = record.FinalPhysicsState, // must observe and adopt this same instance, never race a private
Friction = NormalizeFriction( // projectile candidate into SetProjectileRuntime.
physics.Friction ?? record.Snapshot.Friction), body = _liveEntities.GetOrCreatePhysicsBody(
Elasticity = NormalizeElasticity( record.ServerGuid,
physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f), _ =>
Orientation = entity.Rotation, {
LastUpdateTime = currentTime, var created = new PhysicsBody
}; {
SetVelocity(body, velocity, currentTime); State = record.FinalPhysicsState,
body.Omega = omega; Friction = NormalizeFriction(
body.SnapToCell( physics.Friction ?? record.Snapshot.Friction),
wirePosition.LandblockId, Elasticity = NormalizeElasticity(
entity.Position, physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f),
new Vector3( Orientation = entity.Rotation,
wirePosition.PositionX, LastUpdateTime = currentTime,
wirePosition.PositionY, };
wirePosition.PositionZ)); 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; uint canonicalCellId = body.CellPosition.ObjCellId;
entity.SetPosition(body.Position); entity.SetPosition(body.Position);
entity.Rotation = body.Orientation; entity.Rotation = body.Orientation;
entity.ParentCellId = canonicalCellId; 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( var runtime = new Runtime(
record.Generation, record.Generation,
body, body,
sphere, sphere);
entity.MeshRefs.Count > 0);
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime); _liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
if (HasVisibleCell(record) && !IsHidden(record)) if (HasVisibleCell(record) && !IsHidden(record))
@ -258,8 +303,8 @@ internal sealed class ProjectileController
ShadowPositionSynchronizer.Sync( ShadowPositionSynchronizer.Sync(
_shadows, _shadows,
entity.Id, entity.Id,
entity.Position, body.Position,
entity.Rotation, body.Orientation,
canonicalCellId, canonicalCellId,
liveCenterX, liveCenterX,
liveCenterY); liveCenterY);
@ -282,13 +327,34 @@ internal sealed class ProjectileController
} }
internal bool ApplyAuthoritativeVector( 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 velocity,
Vector3 angularVelocity, Vector3 angularVelocity,
double currentTime) double currentTime)
{ {
ArgumentNullException.ThrowIfNull(expectedRecord);
uint serverGuid = expectedRecord.ServerGuid;
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)) if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime))
return false; return false;
if (!ReferenceEquals(record, expectedRecord)
|| record.VectorAuthorityVersion != expectedVectorAuthorityVersion
|| record.VelocityAuthorityVersion != expectedVelocityAuthorityVersion)
return false;
// Once Missile is cleared, an existing MovementManager owns ordinary // Once Missile is cleared, an existing MovementManager owns ordinary
// jump/vector side effects (Airborne, gravity, LeaveGround) on this // jump/vector side effects (Airborne, gravity, LeaveGround) on this
@ -309,23 +375,48 @@ internal sealed class ProjectileController
} }
_lastFiniteGameTime = currentTime; _lastFiniteGameTime = currentTime;
SetVelocity(runtime.Body, velocity, currentTime); runtime.InvalidatePrediction();
runtime.Body.Omega = angularVelocity; if (!_liveEntities.TryCommitAuthoritativeVector(
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid) record,
&& !IsHidden(record)) runtime.Body,
Deactivate(runtime.Body); velocity,
angularVelocity,
currentTime))
{
return true;
}
return true; return true;
} }
internal bool ApplyAuthoritativeState( 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, PhysicsStateFlags state,
double currentTime, double currentTime,
int liveCenterX, int liveCenterX,
int liveCenterY) int liveCenterY)
{ {
ArgumentNullException.ThrowIfNull(expectedRecord);
uint serverGuid = expectedRecord.ServerGuid;
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)) if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
return false; return false;
if (!ReferenceEquals(record, expectedRecord)
|| record.StateAuthorityVersion != expectedStateAuthorityVersion)
return false;
bool validClock = double.IsFinite(currentTime); bool validClock = double.IsFinite(currentTime);
double effectiveClock = validClock double effectiveClock = validClock
@ -339,6 +430,7 @@ internal sealed class ProjectileController
if (record.ProjectileRuntime is Runtime runtime) if (record.ProjectileRuntime is Runtime runtime)
{ {
runtime.InvalidatePrediction();
bool wasMissile = bool wasMissile =
(runtime.Body.State & PhysicsStateFlags.Missile) != 0; (runtime.Body.State & PhysicsStateFlags.Missile) != 0;
runtime.Body.State = state; runtime.Body.State = state;
@ -398,16 +490,46 @@ internal sealed class ProjectileController
/// streaming origin changes never leak into the canonical cell frame. /// streaming origin changes never leak into the canonical cell frame.
/// </summary> /// </summary>
internal bool ApplyAuthoritativePosition( internal bool ApplyAuthoritativePosition(
uint serverGuid, LiveEntityRecord expectedRecord,
Vector3 worldPosition, Vector3 worldPosition,
Vector3 cellLocalPosition, Vector3 cellLocalPosition,
Quaternion orientation, 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, uint fullCellId,
double currentTime, double currentTime,
int liveCenterX, int liveCenterX,
int liveCenterY) int liveCenterY)
{ {
ArgumentNullException.ThrowIfNull(expectedRecord);
uint serverGuid = expectedRecord.ServerGuid;
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime) if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)
|| !ReferenceEquals(record, expectedRecord)
|| record.PositionAuthorityVersion != expectedPositionAuthorityVersion
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0 || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
return false; return false;
@ -415,6 +537,7 @@ internal sealed class ProjectileController
if (!double.IsFinite(currentTime) if (!double.IsFinite(currentTime)
|| !IsFinite(worldPosition) || !IsFinite(worldPosition)
|| !IsFinite(cellLocalPosition) || !IsFinite(cellLocalPosition)
|| !IsFinite(velocity)
|| !PositionFrameValidation.IsValid( || !PositionFrameValidation.IsValid(
fullCellId, fullCellId,
cellLocalPosition, cellLocalPosition,
@ -427,20 +550,53 @@ internal sealed class ProjectileController
_lastFiniteGameTime = currentTime; _lastFiniteGameTime = currentTime;
PhysicsBody body = runtime.Body; PhysicsBody body = runtime.Body;
runtime.InvalidatePrediction();
bool wasInWorld = body.InWorld; bool wasInWorld = body.InWorld;
body.Orientation = orientation; body.Orientation = orientation;
body.SnapToCell(fullCellId, worldPosition, cellLocalPosition); body.SnapToCell(fullCellId, worldPosition, cellLocalPosition);
body.State = record.FinalPhysicsState; 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; uint canonicalCellId = body.CellPosition.ObjCellId;
entity.SetPosition(worldPosition); entity.SetPosition(worldPosition);
entity.Rotation = orientation; entity.Rotation = orientation;
entity.ParentCellId = canonicalCellId; 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 (HasVisibleCell(record) && !IsHidden(record))
{ {
if (!wasInWorld) 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); Activate(body, currentTime);
}
body.InWorld = true; body.InWorld = true;
ShadowPositionSynchronizer.Sync( ShadowPositionSynchronizer.Sync(
_shadows, _shadows,
@ -466,13 +622,16 @@ internal sealed class ProjectileController
} }
/// <summary> /// <summary>
/// Movement packets use the ordinary MovementManager/animation funnel on /// Selects the retained body's ordinary-physics owner. Missile
/// this same body. The generic remote translation tick consults this gate /// classification always uses the projectile transition path. If the
/// so only the projectile integrator commits its root frame. /// 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.
/// </summary> /// </summary>
internal bool HandlesMovement(uint serverGuid) => internal bool HandlesMovement(uint serverGuid) =>
TryGetCurrent(serverGuid, out LiveEntityRecord record, out _) 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) internal bool TryGetBody(uint serverGuid, out PhysicsBody body)
{ {
@ -501,6 +660,22 @@ internal sealed class ProjectileController
int liveCenterX, int liveCenterX,
int liveCenterY, int liveCenterY,
Vector3? playerWorldPosition) 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)) if (!double.IsFinite(currentTime))
{ {
@ -519,22 +694,50 @@ internal sealed class ProjectileController
continue; continue;
runtime.Body.State = record.FinalPhysicsState; 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 (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); RetailObjectActivityResult hiddenActivity =
} RetailObjectActivityGate.Evaluate(
else record.ObjectClock,
{ runtime.Body,
// UpdatePositionInternal skips root physics/PartArray _liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
// while Hidden, but update_object still advances its is RetailObjectClockDisposition.Advance,
// clock and does not clear Active. record.HasPartArray,
runtime.Body.InWorld = true; (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0,
runtime.Body.LastUpdateTime = currentTime; runtime.Body.Position,
if ((record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0) playerWorldPosition,
Deactivate(runtime.Body); elapsedSeconds);
_shadows.Suspend(entity.Id); if (hiddenActivity is RetailObjectActivityResult.Active)
record.ObjectClock.Advance(elapsedSeconds);
} }
continue; continue;
} }
@ -548,23 +751,6 @@ internal sealed class ProjectileController
// hydrate as an ordinary live object. Restore its retained // hydrate as an ordinary live object. Restore its retained
// shadow/body membership without reactivating projectile // shadow/body membership without reactivating projectile
// integration or consuming the out-of-world clock gap. // 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 // A retained body without MovementManager still remains an
// active CPhysicsObj after Missile is cleared. Continue the // active CPhysicsObj after Missile is cleared. Continue the
// same one-sphere update path; PhysicsEngine reads the final // same one-sphere update path; PhysicsEngine reads the final
@ -573,111 +759,185 @@ internal sealed class ProjectileController
continue; continue;
} }
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)) // Animated owners are admitted by LiveEntityAnimationScheduler so
{ // their PartArray, projectile physics, hooks, and manager tail all
if (!HasVisibleCell(record)) // consume one shared object quantum in retail order.
SuspendOutsideWorld(runtime, entity.Id); if (record.AnimationRuntime is not null)
else
Deactivate(runtime.Body); // Frozen retains its cell/shadow.
continue; continue;
}
if (!runtime.Body.InWorld) RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate(
{ record.ObjectClock,
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(
runtime.Body, runtime.Body,
currentTime, _liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
record.FullCellId, is RetailObjectClockDisposition.Advance,
runtime.CollisionSphere, record.HasPartArray,
entity.Id, (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0,
designatedTargetId: 0u, runtime.Body.Position,
isParented); playerWorldPosition,
elapsedSeconds);
// An owner can be deleted/replaced by a callback reached from a if (activity is not RetailObjectActivityResult.Active)
// 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)
continue; continue;
uint priorCellId = record.FullCellId; RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds);
uint resolvedCellId = result.CellId != 0 for (int qi = 0; qi < batch.Count; qi++)
? 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))
{ {
ShadowPositionSynchronizer.Sync( if (!AdvanceQuantum(
_shadows, record,
entity.Id, batch.GetQuantum(qi),
runtime.Body.Position, liveCenterX,
runtime.Body.Orientation, liveCenterY))
record.FullCellId, break;
liveCenterX, if (record.RemoteMotionRuntime is RemoteMotion remote)
liveCenterY); {
_publishRootPose(entity); RetailObjectManagerTail.Run(
} remote.Host?.TargetManager,
else remote.Movement,
{ partArray: null,
SuspendOutsideWorld(runtime, entity.Id); remote.Host?.PositionManager);
}
} }
} }
} }
/// <summary>
/// Runs the projectile slice of one object quantum already admitted by
/// <see cref="LiveEntityRecord.ObjectClock"/>. Animated projectiles call
/// this between PartArray advancement and hook/manager processing.
/// </summary>
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) private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{ {
if (visible if (record.ProjectileRuntime is not Runtime runtime
|| record.ProjectileRuntime is not Runtime runtime
|| record.WorldEntity is not { } entity || record.WorldEntity is not { } entity
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) || !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record) || !ReferenceEquals(current, record)
@ -686,6 +946,39 @@ internal sealed class ProjectileController
return; 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, // Pending/cell-less records retain their logical projectile runtime,
// but retail exit_world removes them from active physics and collision // but retail exit_world removes them from active physics and collision
// immediately. They are absent from the frame workset after this edge, // immediately. They are absent from the frame workset after this edge,

View file

@ -0,0 +1,145 @@
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Physics;
/// <summary>
/// Applies the remote-object half of retail
/// <c>MovementManager::unpack_movement</c> (<c>0x00524440</c>) after the
/// packet timestamp gate has accepted an UpdateMotion event.
/// </summary>
/// <remarks>
/// The packet owner is deliberately independent of a render PartArray. A
/// normal animated object supplies a <see cref="MotionTableDispatchSink"/>;
/// an animation-less object supplies <see langword="null"/> and
/// <c>CMotionInterp</c> 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.
/// </remarks>
internal sealed class RemoteInboundMotionDispatcher
{
private readonly Func<
MovementManager,
uint,
WorldSession.EntityMotionUpdate,
bool> _routeServerMoveTo;
private readonly Action<IPhysicsObjHost?, uint> _stickToObject;
public RemoteInboundMotionDispatcher(
Func<MovementManager, uint, WorldSession.EntityMotionUpdate, bool>
routeServerMoveTo,
Action<IPhysicsObjHost?, uint> 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<bool>? 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;
}

View file

@ -21,9 +21,7 @@ namespace AcDream.App.Physics;
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER /// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c> /// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is /// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
/// the omega handling (players keep the <c>ObservedOmega||seqOmega</c> world-frame /// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para> /// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
/// ///
/// <para>Shared helpers that GameWindow also calls elsewhere are injected: /// <para>Shared helpers that GameWindow also calls elsewhere are injected:
@ -68,33 +66,87 @@ internal sealed class RemotePhysicsUpdater
AcDream.App.World.LiveEntityRuntime liveEntities, AcDream.App.World.LiveEntityRuntime liveEntities,
uint localPlayerServerGuid, uint localPlayerServerGuid,
float dt, float dt,
System.Action<AcDream.Core.World.WorldEntity> publishRootPose) System.Action<AcDream.Core.World.WorldEntity> publishRootPose,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null,
System.Action<uint>? markPartPoseDirty = null)
{ {
ArgumentNullException.ThrowIfNull(liveEntities); ArgumentNullException.ThrowIfNull(liveEntities);
ArgumentNullException.ThrowIfNull(publishRootPose); 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); liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot);
foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot) foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot)
{ {
if (record.ServerGuid == localPlayerServerGuid if (record.ServerGuid == localPlayerServerGuid
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0 || (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|| record.RemoteMotionRuntime is not RemoteMotion remote || record.RemoteMotionRuntime is not RemoteMotion remote
|| !liveEntities.IsCurrentSpatialRemoteMotion(record, remote) || !liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
continue; continue;
System.Action? handlePartArray = record.AnimationRuntime is AnimatedEntity AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity;
{ Sequencer: { } sequencer } AcDream.Core.Physics.RetailObjectActivityResult activity =
? sequencer.Manager.UseTime AcDream.Core.Physics.RetailObjectActivityGate.Evaluate(
: null; record.ObjectClock,
TickHidden(remote, entity, dt, handlePartArray); 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 // PositionManager callbacks can rebucket, delete, or replace this
// GUID. Publish only while the captured record/runtime pair still // GUID. Publish only while the captured record/runtime pair still
// owns a loaded spatial projection. // owns a loaded spatial projection.
if (liveEntities.IsCurrentSpatialRemoteMotion(record, remote) if (batch.Count > 0
&& ReferenceEquals(record.WorldEntity, entity)) && 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); publishRootPose(entity);
} }
} }
@ -106,28 +158,75 @@ internal sealed class RemotePhysicsUpdater
/// body. <c>serverGuid</c> + the entity id derive from /// body. <c>serverGuid</c> + the entity id derive from
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/> /// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
/// are passed per-call (they change on streaming recentre — never snapshot /// are passed per-call (they change on streaming recentre — never snapshot
/// them in the constructor). <paramref name="rootMotionLocalDelta"/> is /// them in the constructor). <paramref name="rootMotionLocalFrame"/> is
/// the complete local displacement produced by this frame's preceding /// the complete local Frame produced by this frame's preceding
/// <c>CSequence::update</c>, matching retail's /// <c>CSequence::update</c>, matching retail's
/// <c>CPartArray::Update → PositionManager::adjust_offset</c> order. /// <c>CPartArray::Update → PositionManager::adjust_offset</c> order.
/// </summary> /// </summary>
public void Tick( public bool Tick(
RemoteMotion rm, RemoteMotion rm,
AnimatedEntity ae, AnimatedEntity ae,
float dt, float dt,
System.Numerics.Vector3 rootMotionLocalDelta, AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
int liveCenterX, int liveCenterX,
int liveCenterY) int liveCenterY,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
processAnimationHooks = null)
=> Tick(
rm,
ae.Entity,
ae.Scale,
ae.Sequencer,
ae,
dt,
rootMotionLocalFrame,
liveCenterX,
liveCenterY,
processAnimationHooks);
/// <summary>
/// Canonical ordinary-object tick. Render animation is optional: retail
/// walks <c>CPhysics::object_maint</c>, so a live object with a
/// MovementManager or PositionManager must continue even when it has no
/// <c>AnimatedEntity</c> presentation component.
/// </summary>
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<uint, AcDream.Core.Physics.AnimationSequencer>?
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 // #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition // (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's // 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 + // problem" premise) is GONE — every remote now runs the SAME catch-up +
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap // sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO // exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
// player/remote fork; the only surviving player/NPC split is the omega // player/remote fork; only the stale animation-cycle stop below remains
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop. // player/NPC-specific.
// //
// Stop detection stays explicit on packet receipt (UpdateMotion // Stop detection stays explicit on packet receipt (UpdateMotion
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared -> // ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
@ -143,8 +242,8 @@ internal sealed class RemotePhysicsUpdater
// remotes below are explicitly OnWalkable and airborne remotes use // remotes below are explicitly OnWalkable and airborne remotes use
// their authoritative velocity/gravity arc, so this is the same // their authoritative velocity/gravity arc, so this is the same
// branch expressed through our retained runtime state. // branch expressed through our retained runtime state.
System.Numerics.Vector3 scaledRootMotionLocalDelta = !rm.Airborne System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne
? rootMotionLocalDelta * ae.Scale ? rootMotionLocalFrame.Origin * objectScale
: System.Numerics.Vector3.Zero; : System.Numerics.Vector3.Zero;
// Step 1: re-apply current motion commands → body.Velocity. // Step 1: re-apply current motion commands → body.Velocity.
@ -199,11 +298,14 @@ internal sealed class RemotePhysicsUpdater
{ {
rm.ServerVelocity = System.Numerics.Vector3.Zero; rm.ServerVelocity = System.Numerics.Vector3.Zero;
rm.HasServerVelocity = false; rm.HasServerVelocity = false;
_applyServerControlledVelocityCycle( if (animationForVelocityCycle is not null)
serverGuid, {
ae, _applyServerControlledVelocityCycle(
rm, serverGuid,
System.Numerics.Vector3.Zero); animationForVelocityCycle,
rm,
System.Numerics.Vector3.Zero);
}
} }
} }
@ -223,58 +325,10 @@ internal sealed class RemotePhysicsUpdater
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
} }
// Step 2: integrate rotation manually per tick. We can't // Step 2: CSequence's complete Frame carries motion-table omega through
// rely on PhysicsBody.update_object here — its MinQuantum // the same compose as root translation. PhysicsBody.Omega remains
// gate (1/30 s) causes it to SKIP integration when our // reserved for the object's physical angular velocity and is
// 60fps render dt (~0.016s) is below the quantum, meaning // integrated once by UpdatePhysicsInternal below.
// 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 3: integrate physics — retail FUN_005111D0 // Step 3: integrate physics — retail FUN_005111D0
// UpdatePhysicsInternal. Pure Euler: // UpdatePhysicsInternal. Pure Euler:
@ -289,11 +343,10 @@ internal sealed class RemotePhysicsUpdater
// from the UP hard-snap, producing a visible teleport-stride // from the UP hard-snap, producing a visible teleport-stride
// on slopes (the "staircase" the user reported). // on slopes (the "staircase" the user reported).
// //
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal // PlayerMovementController calls UpdatePhysicsInternal directly
// directly for the same reason. Remote motion mirrors that. // for the same reason. Remote motion mirrors that. CSequence's
// Omega is already integrated manually above, so we zero it // authored orientation has already entered the shared delta Frame;
// here to prevent UpdatePhysicsInternal's own omega pass from // Body.Omega remains the separate physical angular-velocity source.
// double-integrating.
var preIntegratePos = rm.Body.Position; var preIntegratePos = rm.Body.Position;
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation → // R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
// Sticky over ONE shared delta frame (PositionManager::adjust_offset // 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. // adds no translation on top of the catch-up — no double-move.
if (rm.Host is { } npcHost) if (rm.Host is { } npcHost)
{ {
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta; AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
if (!rm.Airborne) rm.PositionManagerDeltaScratch;
{ pmDelta.Origin = scaledRootMotionLocalOrigin;
float maxSpeedNpc = rm.Motion.GetMaxSpeed(); pmDelta.Orientation = rootMotionLocalFrame.Orientation;
System.Numerics.Vector3? terrainNormalNpc = float maxSpeedNpc = rm.Motion.GetMaxSpeed();
_physicsEngine.SampleTerrainNormal( System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
rm.Body.Position.X, rm.Body.Position.Y); ? _physicsEngine.SampleTerrainNormal(
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset( rm.Body.Position.X,
dt: (double)dt, rm.Body.Position.Y)
currentBodyPosition: rm.Body.Position, : null;
rootMotionLocalDelta: scaledRootMotionLocalDelta, rm.Position.ComposeOffset(
ori: rm.Body.Orientation, dt,
interp: rm.Interp, rm.Body.Position,
maxSpeed: maxSpeedNpc, rm.Body.Orientation,
terrainNormal: terrainNormalNpc); pmDelta,
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame rm.Interp,
{ maxSpeedNpc,
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( pmDelta,
rm.Body.Orientation, offsetNpc), terrainNormalNpc,
}; inContact: rm.Body.InContact);
}
else
{
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
}
npcHost.PositionManager.AdjustOffset(pmDelta, dt); npcHost.PositionManager.AdjustOffset(pmDelta, dt);
ApplyPositionManagerDelta(rm.Body, pmDelta); ApplyPositionManagerDelta(rm.Body, pmDelta);
} }
else if (!rm.Airborne) else
{ {
// No PositionManager host yet (pre-binding): apply the catch-up // No PositionManager host yet (pre-binding): apply the catch-up
// directly, matching Path A's fallback (:10202). // 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(); float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc = System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
_physicsEngine.SampleTerrainNormal( ? _physicsEngine.SampleTerrainNormal(
rm.Body.Position.X, rm.Body.Position.Y); rm.Body.Position.X,
rm.Body.Position += rm.Position.ComputeOffset( rm.Body.Position.Y)
dt: (double)dt, : null;
currentBodyPosition: rm.Body.Position, rm.Position.ComposeOffset(
rootMotionLocalDelta: scaledRootMotionLocalDelta, dt,
ori: rm.Body.Orientation, rm.Body.Position,
interp: rm.Interp, rm.Body.Orientation,
maxSpeed: maxSpeedNpc, pmDelta,
terrainNormal: terrainNormalNpc); rm.Interp,
maxSpeedNpc,
pmDelta,
terrainNormalNpc,
inContact: rm.Body.InContact);
ApplyPositionManagerDelta(rm.Body, pmDelta);
} }
rm.Body.calc_acceleration(); rm.Body.calc_acceleration();
rm.Body.UpdatePhysicsInternal(dt); 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; var postIntegratePos = rm.Body.Position;
uint committedCellId = rm.CellId;
// Step 4: collision sweep — retail FUN_00514B90 → // Step 4: collision sweep — retail FUN_00514B90 →
// FUN_005148A0 → Transition::FindTransitionalPosition. // FUN_005148A0 → Transition::FindTransitionalPosition.
@ -399,7 +471,7 @@ internal sealed class RemotePhysicsUpdater
// Fallback to the human capsule for a shapeless / unresolvable // Fallback to the human capsule for a shapeless / unresolvable
// Setup (GetSetupCylinder returns (0,0)); a zero radius would // Setup (GetSetupCylinder returns (0,0)); a zero radius would
// degenerate the sweep. // 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; } if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
var resolveResult = _physicsEngine.ResolveWithTransition( var resolveResult = _physicsEngine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId, preIntegratePos, postIntegratePos, rm.CellId,
@ -442,11 +514,11 @@ internal sealed class RemotePhysicsUpdater
// the remote's own cylinder and produces ~1m of // the remote's own cylinder and produces ~1m of
// horizontal drift on the first jump frame // horizontal drift on the first jump frame
// (validated by [SWEEP-OBJ] traces). // (validated by [SWEEP-OBJ] traces).
movingEntityId: ae.Entity.Id); movingEntityId: entity.Id);
rm.Body.Position = resolveResult.Position; rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0) if (resolveResult.CellId != 0)
rm.CellId = resolveResult.CellId; committedCellId = resolveResult.CellId;
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing // #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail // 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 // player would collide with a shadow offset from where the monster
// renders (the reverted attempt's "stuck on an invisible monster"). // renders (the reverted attempt's "stuck on an invisible monster").
// Syncing the shadow to the resolved body every tick makes the // Syncing the shadow to the resolved body every tick makes the
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap // de-overlap PERSIST and keeps collision == render. Re-flood is
// MOVEMENT-GATED (#184 review): re-flood only when the resolved // POSE/CELL-GATED (#184 review): re-flood only when the resolved
// body moved > ~1 cm since the last shadow registration. This is // 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 // 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 // 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 // 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 // to actually-moving remotes — the perf risk the review flagged for
// a packed town. (In-place shadow-move + cell-relink-on-change is a // a packed town. (In-place shadow-move + cell-relink-on-change is a
// further optimization if profiling still shows churn.) // 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 // #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
// (pc:282699-282715) runs after EVERY SetPositionInternal — // (pc:282699-282715) runs after EVERY SetPositionInternal —
// remote objects included; a VectorUpdate-launched jump arc // remote objects included; a VectorUpdate-launched jump arc
@ -578,21 +644,69 @@ internal sealed class RemotePhysicsUpdater
// airborne UseTime contact gate; without it a // airborne UseTime contact gate; without it a
// chasing NPC that lands stalls until ACE's // chasing NPC that lands stalls until ACE's
// ~1 Hz re-emit. // ~1 Hz re-emit.
ulong landingStateAuthorityVersion =
ownerRecord?.StateAuthorityVersion ?? 0UL;
rm.Movement.HitGround(); rm.Movement.HitGround();
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
// DR bookkeeping only (partner of the jump-start // DR bookkeeping only (partner of the jump-start
// `State |= Gravity`): stops the per-tick gravity // `State |= Gravity`): stops the per-tick gravity
// integration for the grounded body. // 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") if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); 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 // SetPositionInternal commits the resolved body/contact result and
if (rm.CellId != 0) // root frame as one object before changing cell membership. The
ae.Entity.ParentCellId = rm.CellId; // canonical CellId writer can synchronously rebucket loaded to
ae.Entity.Rotation = rm.Body.Orientation; // 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 — // R5-V3 (#171): retail UpdateObjectInternal tail —
@ -602,21 +716,17 @@ internal sealed class RemotePhysicsUpdater
// sticky 1 s lease watchdog (StickyManager::UseTime // sticky 1 s lease watchdog (StickyManager::UseTime
// 0x00555610 — a stick not re-issued by a fresh server arm // 0x00555610 — a stick not re-issued by a fresh server arm
// within 1 s tears itself down). No-op while nothing is stuck. // 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( AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null, rm.Host?.TargetManager,
handleTargeting, rm.Movement,
movementUseTime: rm.Movement.UseTime, sequencer?.Manager,
partArrayHandleMovement, rm.Host?.PositionManager);
positionUseTime); return IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch);
} }
/// <summary> /// <summary>
@ -628,37 +738,69 @@ internal sealed class RemotePhysicsUpdater
/// position managers still consume time. Physics-script and particle owners /// position managers still consume time. Physics-script and particle owners
/// tick later in the shared frame pipeline. /// tick later in the shared frame pipeline.
/// </summary> /// </summary>
public void TickHidden( public bool TickHidden(
RemoteMotion rm, RemoteMotion rm,
AcDream.Core.World.WorldEntity entity, AcDream.Core.World.WorldEntity entity,
float dt, float dt,
System.Action? partArrayHandleMovement = null) AcDream.Core.Physics.Motion.MotionTableManager?
partArrayHandleMovement = null,
System.Action<uint, AcDream.Core.Physics.AnimationSequencer>?
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(rm);
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
if (!IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch))
{
return false;
}
System.Numerics.Vector3 preComposePosition = rm.Body.Position; System.Numerics.Vector3 preComposePosition = rm.Body.Position;
// The part-array contribution is the identity frame while Hidden. // The part-array contribution is the identity frame while Hidden.
// Interpolation is the first PositionManager stage in retail; acdream // Interpolation is the first PositionManager stage in retail; acdream
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint. // 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, dt,
rm.Body.Position, rm.Body.Position,
System.Numerics.Vector3.Zero,
rm.Body.Orientation, rm.Body.Orientation,
positionDelta,
rm.Interp, rm.Interp,
rm.Motion.GetMaxSpeed()); rm.Motion.GetMaxSpeed(),
var positionDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta,
{ inContact: rm.Body.InContact);
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
rm.Body.Orientation,
interpolationOffset),
};
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt); rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
ApplyPositionManagerDelta(rm.Body, positionDelta); 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; System.Numerics.Vector3 composedPosition = rm.Body.Position;
uint committedCellId = rm.CellId;
if (rm.CellId != 0 if (rm.CellId != 0
&& composedPosition != preComposePosition && composedPosition != preComposePosition
&& _physicsEngine.LandblockCount > 0) && _physicsEngine.LandblockCount > 0)
@ -689,8 +831,8 @@ internal sealed class RemotePhysicsUpdater
movingEntityId: entity.Id); movingEntityId: entity.Id);
rm.Body.Position = resolved.Position; rm.Body.Position = resolved.Position;
if (resolved.CellId != 0) if (resolved.CellId != 0)
rm.CellId = resolved.CellId; committedCellId = resolved.CellId;
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition( if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
rm.Body, rm.Body,
resolved.InContact, resolved.InContact,
resolved.OnWalkable, resolved.OnWalkable,
@ -699,39 +841,71 @@ internal sealed class RemotePhysicsUpdater
previousContact, previousContact,
previousOnWalkable, previousOnWalkable,
rm.Movement.HitGround, rm.Movement.HitGround,
rm.Motion.LeaveGround); rm.Motion.LeaveGround,
() => IsCurrentOwner(
ownerRuntime,
ownerRecord,
rm,
entity,
ownerClockEpoch)))
{
return false;
}
rm.Airborne = !rm.Body.OnWalkable; 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); entity.SetPosition(rm.Body.Position);
if (rm.CellId != 0) entity.ParentCellId = committedCellId;
entity.ParentCellId = rm.CellId;
entity.Rotation = rm.Body.Orientation; 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( AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null, rm.Host?.TargetManager,
handleTargeting, rm.Movement,
movementUseTime: rm.Movement.UseTime,
partArrayHandleMovement, 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));
/// <summary> /// <summary>
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/> /// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's /// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
/// stand-in for retail's <c>Frame::combine</c> in /// stand-in for retail's <c>Frame::combine</c> in
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine /// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it /// <c>globaltolocalvec</c> output — 0x00555430), so combining rotates it
/// out by the body orientation. An untouched (identity) rotation means "no /// out by the body's current orientation and post-multiplies the complete
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition /// relative orientation. Moved from GameWindow (#184 Slice 2a); the
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
/// DR tick is its only caller. /// DR tick is its only caller.
/// </summary> /// </summary>
private static void ApplyPositionManagerDelta( private static void ApplyPositionManagerDelta(
@ -741,10 +915,10 @@ internal sealed class RemotePhysicsUpdater
if (delta.Origin != System.Numerics.Vector3.Zero) if (delta.Origin != System.Numerics.Vector3.Zero)
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation); body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
if (!delta.Orientation.IsIdentity) if (!delta.Orientation.IsIdentity)
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
body.Position,
body.Orientation, body.Orientation,
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation) body.Orientation * delta.Orientation);
+ delta.GetHeading());
} }
/// <summary> /// <summary>
@ -756,7 +930,10 @@ internal sealed class RemotePhysicsUpdater
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is /// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>) /// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
/// rather than snapshotted, since it moves on recentre. Updates /// rather than snapshotted, since it moves on recentre. Updates
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate. /// <see cref="RemoteMotion.LastShadowSyncPos"/> and
/// <see cref="RemoteMotion.LastShadowSyncOrientation"/> 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 /// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
/// UP-branch tail. /// UP-branch tail.
/// </summary> /// </summary>
@ -774,8 +951,53 @@ internal sealed class RemotePhysicsUpdater
liveCenterY, liveCenterY,
authoritativeCellId ?? rm.CellId); authoritativeCellId ?? rm.CellId);
rm.LastShadowSyncPosition = rm.Body.Position; 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( public void SyncRemoteShadowToBody(
uint entityId, uint entityId,
AcDream.Core.Physics.PhysicsBody body, AcDream.Core.Physics.PhysicsBody body,

View file

@ -65,9 +65,13 @@ internal sealed class RemoteTeleportController : IDisposable
bool ContactResolved, bool ContactResolved,
Vector3 Position, Vector3 Position,
uint CellId, uint CellId,
Quaternion Orientation); Quaternion Orientation,
bool Superseded = false);
private readonly record struct PendingPlacement( private readonly record struct PendingPlacement(
LiveEntityRecord Record,
ulong PositionAuthorityVersion,
ulong VelocityAuthorityVersion,
ILiveEntityRemotePlacementRuntime Remote, ILiveEntityRemotePlacementRuntime Remote,
PhysicsBody Body, PhysicsBody Body,
WorldEntity Entity, WorldEntity Entity,
@ -95,9 +99,53 @@ internal sealed class RemoteTeleportController : IDisposable
bool Airborne, bool Airborne,
Vector3 LastServerPosition, Vector3 LastServerPosition,
double LastServerPositionTime, double LastServerPositionTime,
Vector3 LastShadowSyncPosition); Vector3 LastShadowSyncPosition,
Quaternion LastShadowSyncOrientation);
/// <summary>
/// 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.
/// </summary>
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( internal Result TryApply(
LiveEntityRecord expectedRecord,
ulong expectedPositionAuthorityVersion,
ulong expectedVelocityAuthorityVersion,
ILiveEntityRemotePlacementRuntime remote, ILiveEntityRemotePlacementRuntime remote,
WorldEntity entity, WorldEntity entity,
Vector3 requestedWorldPosition, Vector3 requestedWorldPosition,
@ -111,8 +159,15 @@ internal sealed class RemoteTeleportController : IDisposable
{ {
ArgumentNullException.ThrowIfNull(remote); ArgumentNullException.ThrowIfNull(remote);
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(expectedRecord);
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord) if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord)
|| !ReferenceEquals(liveRecord, expectedRecord)
|| !_liveEntities.IsCurrentPositionAuthority(
expectedRecord,
expectedPositionAuthorityVersion)
|| liveRecord.Generation != generation || liveRecord.Generation != generation
|| !ReferenceEquals(liveRecord.WorldEntity, entity)
|| !ReferenceEquals(liveRecord.RemoteMotionRuntime, remote)
|| liveRecord.PhysicsBody is null || liveRecord.PhysicsBody is null
|| !ReferenceEquals(liveRecord.PhysicsBody, remote.Body)) || !ReferenceEquals(liveRecord.PhysicsBody, remote.Body))
{ {
@ -124,6 +179,7 @@ internal sealed class RemoteTeleportController : IDisposable
bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable; bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable;
RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody); RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody);
if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior) if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior)
&& ReferenceEquals(prior.Record, liveRecord)
&& prior.Generation == generation) && prior.Generation == generation)
{ {
wasInContact = prior.WasInContact; wasInContact = prior.WasInContact;
@ -132,6 +188,9 @@ internal sealed class RemoteTeleportController : IDisposable
} }
PendingPlacement request = new( PendingPlacement request = new(
liveRecord,
expectedPositionAuthorityVersion,
expectedVelocityAuthorityVersion,
remote, remote,
liveRecord.PhysicsBody, liveRecord.PhysicsBody,
entity, entity,
@ -146,7 +205,8 @@ internal sealed class RemoteTeleportController : IDisposable
rollback); rollback);
if (!destinationProjectionVisible) if (!destinationProjectionVisible)
{ {
ParkPending(request, requestedCellLocalPosition); if (!ParkPending(request, requestedCellLocalPosition))
return Superseded(request);
return new Result( return new Result(
true, true,
false, false,
@ -155,10 +215,13 @@ internal sealed class RemoteTeleportController : IDisposable
requestedOrientation); requestedOrientation);
} }
ResolveResult placement = Resolve(request); ResolveResult placement = Resolve(request);
if (!IsCurrent(request))
return Superseded(request);
if (!placement.Ok) if (!placement.Ok)
{ {
_pending.Remove(entity.ServerGuid); _pending.Remove(entity.ServerGuid);
RollBackDeferredPlacement(request); if (!RollBackDeferredPlacement(request))
return Superseded(request);
return new Result( return new Result(
false, false,
false, false,
@ -250,7 +313,9 @@ internal sealed class RemoteTeleportController : IDisposable
private Result CommitResolved(PendingPlacement request, ResolveResult placement) private Result CommitResolved(PendingPlacement request, ResolveResult placement)
{ {
RemoteTeleportPlacement.Apply( if (!IsCurrent(request))
return Superseded(request);
if (!RemoteTeleportPlacement.Apply(
request.Remote, request.Remote,
request.Body, request.Body,
placement, placement,
@ -258,16 +323,29 @@ internal sealed class RemoteTeleportController : IDisposable
request.RequestedOrientation, request.RequestedOrientation,
request.GameTime, request.GameTime,
request.WasInContact, 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) if (request.Remote.CellId != placement.CellId)
request.Remote.CellId = placement.CellId; request.Remote.CellId = placement.CellId;
if (!IsCurrent(request))
return Superseded(request);
request.Remote.LastServerPosition = placement.Position; request.Remote.LastServerPosition = placement.Position;
request.Remote.LastServerPositionTime = request.GameTime; request.Remote.LastServerPositionTime = request.GameTime;
request.Remote.LastShadowSyncPosition = Vector3.Zero; request.Remote.LastShadowSyncPosition = Vector3.Zero;
request.Remote.LastShadowSyncOrientation = Quaternion.Zero;
request.Entity.SetPosition(request.Body.Position); request.Entity.SetPosition(request.Body.Position);
request.Entity.ParentCellId = placement.CellId; request.Entity.ParentCellId = placement.CellId;
request.Entity.Rotation = request.Body.Orientation; 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 = bool deferShadowRestore =
record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden) record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
@ -276,10 +354,15 @@ internal sealed class RemoteTeleportController : IDisposable
request.Entity.ServerGuid, request.Entity.ServerGuid,
request.Generation, request.Generation,
deferShadowRestore); deferShadowRestore);
if (!IsCurrent(request))
return Superseded(request);
if (!deferShadowRestore) if (!deferShadowRestore)
{ {
request.Remote.LastShadowSyncPosition = request.Body.Position; request.Remote.LastShadowSyncPosition = request.Body.Position;
request.Remote.LastShadowSyncOrientation = request.Body.Orientation;
_syncResolvedShadow(request.Entity, request.Body, placement.CellId); _syncResolvedShadow(request.Entity, request.Body, placement.CellId);
if (!IsCurrent(request))
return Superseded(request);
} }
} }
return new Result( return new Result(
@ -290,8 +373,10 @@ internal sealed class RemoteTeleportController : IDisposable
request.Body.Orientation); 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.Orientation = request.RequestedOrientation;
request.Body.SnapToCell( request.Body.SnapToCell(
request.RequestedCellId, request.RequestedCellId,
@ -308,13 +393,19 @@ internal sealed class RemoteTeleportController : IDisposable
request.Remote.Airborne = true; request.Remote.Airborne = true;
if (request.Remote.CellId != request.RequestedCellId) if (request.Remote.CellId != request.RequestedCellId)
request.Remote.CellId = request.RequestedCellId; request.Remote.CellId = request.RequestedCellId;
if (!IsCurrent(request))
return false;
request.Remote.LastServerPosition = request.RequestedWorldPosition; request.Remote.LastServerPosition = request.RequestedWorldPosition;
request.Remote.LastServerPositionTime = request.GameTime; request.Remote.LastServerPositionTime = request.GameTime;
request.Remote.LastShadowSyncPosition = Vector3.Zero; request.Remote.LastShadowSyncPosition = Vector3.Zero;
request.Remote.LastShadowSyncOrientation = Quaternion.Zero;
request.Entity.SetPosition(request.RequestedWorldPosition); request.Entity.SetPosition(request.RequestedWorldPosition);
request.Entity.ParentCellId = request.RequestedCellId; request.Entity.ParentCellId = request.RequestedCellId;
request.Entity.Rotation = request.RequestedOrientation; request.Entity.Rotation = request.RequestedOrientation;
if (!IsCurrent(request))
return false;
_pending[request.Entity.ServerGuid] = request; _pending[request.Entity.ServerGuid] = request;
return true;
} }
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) 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 (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending))
{ {
if (record.WorldEntity is null if (record.WorldEntity is null
|| !ReferenceEquals(record, pending.Record)
|| !ReferenceEquals(record.WorldEntity, pending.Entity) || !ReferenceEquals(record.WorldEntity, pending.Entity)
|| record.Generation != pending.Generation) || record.Generation != pending.Generation)
{ {
@ -351,7 +443,10 @@ internal sealed class RemoteTeleportController : IDisposable
pending = pending with { Remote = currentRemote }; pending = pending with { Remote = currentRemote };
_pending[record.ServerGuid] = pending; _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 // A newer accepted UpdatePosition rebucketed the projection
// before its TryApply call could replace this request. Keep // before its TryApply call could replace this request. Keep
@ -361,6 +456,8 @@ internal sealed class RemoteTeleportController : IDisposable
_pending.Remove(record.ServerGuid); _pending.Remove(record.ServerGuid);
ResolveResult placement = Resolve(pending); ResolveResult placement = Resolve(pending);
if (!IsCurrent(pending))
return;
if (!placement.Ok) if (!placement.Ok)
RollBackDeferredPlacement(pending); RollBackDeferredPlacement(pending);
else else
@ -388,11 +485,14 @@ internal sealed class RemoteTeleportController : IDisposable
remote.Airborne, remote.Airborne,
remote.LastServerPosition, remote.LastServerPosition,
remote.LastServerPositionTime, 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; RollbackPlacement rollback = pending.Rollback;
PhysicsBody body = pending.Body; PhysicsBody body = pending.Body;
body.Orientation = rollback.Orientation; body.Orientation = rollback.Orientation;
@ -412,21 +512,26 @@ internal sealed class RemoteTeleportController : IDisposable
pending.Remote.LastServerPosition = rollback.LastServerPosition; pending.Remote.LastServerPosition = rollback.LastServerPosition;
pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime; pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime;
pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition; pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition;
pending.Remote.LastShadowSyncOrientation = rollback.LastShadowSyncOrientation;
pending.Entity.SetPosition(rollback.Position); pending.Entity.SetPosition(rollback.Position);
pending.Entity.ParentCellId = rollback.CellId; pending.Entity.ParentCellId = rollback.CellId;
pending.Entity.Rotation = rollback.Orientation; pending.Entity.Rotation = rollback.Orientation;
if (!IsCurrentAuthority(pending))
return false;
if (rollback.CellId == 0) if (rollback.CellId == 0)
_liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid); _liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid);
else else
_liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId); _liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId);
if (!_liveEntities.TryGetRecord( if (!IsCurrentAuthority(pending)
|| !_liveEntities.TryGetRecord(
pending.Entity.ServerGuid, pending.Entity.ServerGuid,
out LiveEntityRecord restored) out LiveEntityRecord restored)
|| !ReferenceEquals(restored, pending.Record)
|| restored.Generation != pending.Generation) || restored.Generation != pending.Generation)
{ {
return; return false;
} }
if (rollback.CellId == 0) if (rollback.CellId == 0)
@ -435,7 +540,7 @@ internal sealed class RemoteTeleportController : IDisposable
pending.Entity.ServerGuid, pending.Entity.ServerGuid,
pending.Generation, pending.Generation,
false); false);
return; return IsCurrentAuthority(pending);
} }
bool deferShadowRestore = bool deferShadowRestore =
@ -445,13 +550,42 @@ internal sealed class RemoteTeleportController : IDisposable
pending.Entity.ServerGuid, pending.Entity.ServerGuid,
pending.Generation, pending.Generation,
deferShadowRestore); deferShadowRestore);
if (!IsCurrentAuthority(pending))
return false;
if (deferShadowRestore) if (deferShadowRestore)
return; return true;
pending.Remote.LastShadowSyncPosition = Vector3.Zero; pending.Remote.LastShadowSyncPosition = Vector3.Zero;
pending.Remote.LastShadowSyncOrientation = Quaternion.Zero;
_syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId); _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) => private static bool IsPlayerGuid(uint guid) =>
(guid & 0xFF000000u) == 0x50000000u; (guid & 0xFF000000u) == 0x50000000u;
} }

View file

@ -12,7 +12,9 @@ public static class RemoteTeleportHook
{ {
private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu; private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu;
public static void Execute(RemoteTeleportHookActions actions) public static bool Execute(
RemoteTeleportHookActions actions,
Func<bool>? isCurrent = null)
{ {
ArgumentNullException.ThrowIfNull(actions.CancelMoveTo); ArgumentNullException.ThrowIfNull(actions.CancelMoveTo);
ArgumentNullException.ThrowIfNull(actions.UnStick); ArgumentNullException.ThrowIfNull(actions.UnStick);
@ -21,12 +23,26 @@ public static class RemoteTeleportHook
ArgumentNullException.ThrowIfNull(actions.NotifyTeleported); ArgumentNullException.ThrowIfNull(actions.NotifyTeleported);
ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd); ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd);
bool Current() => isCurrent?.Invoke() ?? true;
if (!Current())
return false;
actions.CancelMoveTo(TeleportCancelContext); actions.CancelMoveTo(TeleportCancelContext);
if (!Current())
return false;
actions.UnStick(); actions.UnStick();
if (!Current())
return false;
actions.StopInterpolating(); actions.StopInterpolating();
if (!Current())
return false;
actions.UnConstrain(); actions.UnConstrain();
if (!Current())
return false;
actions.NotifyTeleported(); actions.NotifyTeleported();
if (!Current())
return false;
actions.ReportCollisionEnd(); actions.ReportCollisionEnd();
return Current();
} }
} }

View file

@ -12,7 +12,7 @@ namespace AcDream.App.Physics;
/// </summary> /// </summary>
internal static class RemoteTeleportPlacement internal static class RemoteTeleportPlacement
{ {
internal static void Apply( internal static bool Apply(
ILiveEntityRemotePlacementRuntime remote, ILiveEntityRemotePlacementRuntime remote,
PhysicsBody body, PhysicsBody body,
ResolveResult placement, ResolveResult placement,
@ -20,7 +20,9 @@ internal static class RemoteTeleportPlacement
Quaternion orientation, Quaternion orientation,
double gameTime, double gameTime,
bool previousContact, bool previousContact,
bool previousOnWalkable) bool previousOnWalkable,
Func<bool>? isCurrent = null,
Func<bool>? isVelocityCurrent = null)
{ {
ArgumentNullException.ThrowIfNull(remote); ArgumentNullException.ThrowIfNull(remote);
ArgumentNullException.ThrowIfNull(body); ArgumentNullException.ThrowIfNull(body);
@ -55,7 +57,7 @@ internal static class RemoteTeleportPlacement
body.ContactPlaneIsWater = false; body.ContactPlaneIsWater = false;
} }
PhysicsObjUpdate.CommitSetPositionTransition( if (!PhysicsObjUpdate.CommitSetPositionTransition(
body, body,
placement.InContact, placement.InContact,
placement.OnWalkable, placement.OnWalkable,
@ -64,9 +66,15 @@ internal static class RemoteTeleportPlacement
previousContact, previousContact,
previousOnWalkable, previousOnWalkable,
remote.HitGround, remote.HitGround,
remote.LeaveGround); remote.LeaveGround,
remote.Airborne = !body.OnWalkable; isCurrent,
isVelocityCurrent))
{
return false;
}
if (isVelocityCurrent?.Invoke() != false)
remote.Airborne = !body.OnWalkable;
return isCurrent?.Invoke() ?? true;
} }
private static bool IsFinite(Vector3 value) => private static bool IsFinite(Vector3 value) =>

File diff suppressed because it is too large Load diff

View file

@ -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;
/// <summary>
/// Owns retail's ordinary live-object workset. <c>CPhysics::UseTime</c>
/// (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 <see cref="LiveEntityRecord"/>.
/// </summary>
internal sealed class LiveEntityAnimationScheduler
{
private readonly Func<LiveEntityRuntime?> _liveEntities;
private readonly Func<uint> _localPlayerGuid;
private readonly RemotePhysicsUpdater _remotePhysics;
private readonly LiveEntityOrdinaryPhysicsUpdater _ordinaryPhysics;
private readonly Func<ProjectileController?> _projectiles;
private readonly Action<WorldEntity> _publishRootPose;
private readonly Action<uint, AnimationSequencer> _captureAnimationHooks;
private readonly List<LiveEntityRecord> _rootSnapshot = new();
private readonly Dictionary<uint, LiveEntityAnimationSchedule> _schedules = new();
private readonly Frame _rootFrameScratch = new();
private readonly MotionDeltaFrame _rootDeltaScratch = new();
public LiveEntityAnimationScheduler(
Func<LiveEntityRuntime?> liveEntities,
Func<uint> localPlayerGuid,
RemotePhysicsUpdater remotePhysics,
LiveEntityOrdinaryPhysicsUpdater ordinaryPhysics,
Func<ProjectileController?> projectiles,
Action<WorldEntity> publishRootPose,
Action<uint, AnimationSequencer> 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));
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> Tick(
float elapsedSeconds,
Vector3? playerPosition,
bool localHiddenPartPoseDirty,
int liveCenterX,
int liveCenterY,
Action<uint, AnimatedEntity>? 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<PartTransform>? 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<PartTransform>? 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<PartTransform>? SequenceFrames,
float LegacyAdvanceSeconds,
bool ComposeParts);

View file

@ -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;
/// <summary>
/// Ports retail's separate <c>CPhysics::static_animating_objects</c> workset
/// (<c>CPhysics::UseTime</c> 0x00509950 and
/// <c>CPhysicsObj::animate_static_object</c> 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.
/// </summary>
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<uint, uint>?[] SurfaceOverrides;
public required bool[] PartAvailable;
public PhysicsBody? Body;
public AnimationSequencer? PendingProcessHooks;
public ulong PendingResidencyVersion;
public IReadOnlyList<PartTransform>? PreparedLivePartFrames;
public double ElapsedSinceUpdate;
public readonly Frame RootFrameScratch = new();
public readonly List<MeshRef> MeshRefs = new();
public readonly List<Matrix4x4> PartPoses = new();
}
private readonly IAnimationLoader _animationLoader;
private readonly Action<uint, AnimationSequencer> _captureHooks;
private readonly Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>>
_publishPartPoses;
private readonly Func<WorldEntity, bool> _isResident;
private readonly Action<WorldEntity, PhysicsBody> _commitLiveRoot;
private readonly Func<WorldEntity, ulong> _residencyVersion;
private readonly Dictionary<uint, Owner> _owners = new();
private readonly List<Owner> _snapshot = new();
private readonly List<Owner> _hookSnapshot = new();
public RetailStaticAnimatingObjectScheduler(
IAnimationLoader animationLoader,
Action<uint, AnimationSequencer> captureHooks,
Action<WorldEntity, IReadOnlyList<Matrix4x4>, IReadOnlyList<bool>> publishPartPoses,
Func<WorldEntity, bool>? isResident = null,
Action<WorldEntity, PhysicsBody>? commitLiveRoot = null,
Func<WorldEntity, ulong>? 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,
});
}
/// <summary>
/// Completes a live Physics-Static registration with the same sequencer
/// and body owned by the canonical live entity.
/// Retail's static workset stores a <c>CPhysicsObj*</c>; it never clones a
/// second PartArray for this alternate scheduling path.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public bool TryTakeLivePartFrames(
uint ownerId,
out IReadOnlyList<PartTransform> 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<PartTransform>();
return false;
}
public void Unregister(uint ownerId) => _owners.Remove(ownerId);
public void CopyAnimatedEntityIdsTo(HashSet<uint> 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<PartTransform> 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;
}
}
/// <summary>
/// Runs the static workset's retail <c>process_hooks</c> tail after final
/// root, part, and child pose publication. Presentation hooks remain in
/// the shared frame queue until its normal drain.
/// </summary>
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<PartTransform> 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<uint, uint>?[] MatchSurfaceOverrides(
WorldEntity entity,
uint[] gfxIds,
bool[] available)
{
var result = new IReadOnlyDictionary<uint, uint>?[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;
}
}

View file

@ -0,0 +1,90 @@
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Commits a changed root produced by retail's
/// <c>CPhysicsObj::animate_static_object</c> to the retained effect pose and
/// collision-shadow projections of the same live object incarnation.
/// </summary>
internal sealed class StaticLiveRootCommitter
{
private readonly Func<LiveEntityRuntime?> _runtime;
private readonly ShadowObjectRegistry _shadows;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action<WorldEntity> _updateEffectRoot;
public StaticLiveRootCommitter(
Func<LiveEntityRuntime?> runtime,
ShadowObjectRegistry shadows,
Func<(int X, int Y)> liveCenter,
Action<WorldEntity> 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;
}
}

View file

@ -20,6 +20,7 @@ public sealed class AnimationHookFrameQueue
{ {
private readonly AnimationHookRouter _router; private readonly AnimationHookRouter _router;
private readonly IEntityEffectPoseSource _poses; private readonly IEntityEffectPoseSource _poses;
private readonly IEntityEffectPoseLifetimeSource? _poseLifetimes;
private readonly List<Entry> _entries = new(); private readonly List<Entry> _entries = new();
public AnimationHookFrameQueue( public AnimationHookFrameQueue(
@ -28,6 +29,7 @@ public sealed class AnimationHookFrameQueue
{ {
_router = router ?? throw new ArgumentNullException(nameof(router)); _router = router ?? throw new ArgumentNullException(nameof(router));
_poses = poses ?? throw new ArgumentNullException(nameof(poses)); _poses = poses ?? throw new ArgumentNullException(nameof(poses));
_poseLifetimes = poses as IEntityEffectPoseLifetimeSource;
} }
public int Count => _entries.Count; public int Count => _entries.Count;
@ -45,6 +47,12 @@ public sealed class AnimationHookFrameQueue
{ {
ArgumentNullException.ThrowIfNull(sequencer); ArgumentNullException.ThrowIfNull(sequencer);
ArgumentNullException.ThrowIfNull(hooks); 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 // Retail CPhysicsObj::process_hooks (0x00511550) executes AnimDone
// inside UpdatePositionInternal, before the transition and every // 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. // authored hook stream after the final part poses are published.
for (int i = 0; i < hooks.Count; i++) 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) if (hooks[i] is AnimationDoneHook)
sequencer.Manager.AnimationDone(success: true); sequencer.Manager.AnimationDone(success: true);
} }
if (hooks.Count == 0)
return;
_entries.Add(new Entry( _entries.Add(new Entry(
ownerLocalId, ownerLocalId,
ownerLifetimeVersion,
hooks)); hooks));
} }
@ -69,19 +90,35 @@ public sealed class AnimationHookFrameQueue
for (int i = 0; i < _entries.Count; i++) for (int i = 0; i < _entries.Count; i++)
{ {
Entry entry = _entries[i]; Entry entry = _entries[i];
bool hasRootPose = _poses.TryGetRootPose( if (_poseLifetimes is not null
entry.OwnerLocalId, && _poseLifetimes.GetPoseOwnerLifetimeVersion(
out Matrix4x4 rootWorld); entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
Vector3 worldPosition = hasRootPose {
? rootWorld.Translation continue;
: Vector3.Zero; }
for (int hi = 0; hi < entry.Hooks.Count; hi++) 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]; AnimationHook? hook = entry.Hooks[hi];
if (hook is null) if (hook is null)
continue; continue;
if (hasRootPose) if (_poses.TryGetRootPose(
_router.OnHook(entry.OwnerLocalId, worldPosition, hook); entry.OwnerLocalId,
out Matrix4x4 rootWorld))
{
_router.OnHook(
entry.OwnerLocalId,
rootWorld.Translation,
hook);
}
} }
} }
_entries.Clear(); _entries.Clear();
@ -91,5 +128,6 @@ public sealed class AnimationHookFrameQueue
private readonly record struct Entry( private readonly record struct Entry(
uint OwnerLocalId, uint OwnerLocalId,
ulong OwnerLifetimeVersion,
IReadOnlyList<AnimationHook> Hooks); IReadOnlyList<AnimationHook> Hooks);
} }

View file

@ -18,7 +18,8 @@ namespace AcDream.App.Rendering.Vfx;
public sealed class EntityEffectPoseRegistry : public sealed class EntityEffectPoseRegistry :
IEntityEffectPoseSource, IEntityEffectPoseSource,
IEntityEffectCellSource, IEntityEffectCellSource,
IEntityEffectPoseChangeSource IEntityEffectPoseChangeSource,
IEntityEffectPoseLifetimeSource
{ {
private sealed class PoseRecord private sealed class PoseRecord
{ {
@ -26,9 +27,11 @@ public sealed class EntityEffectPoseRegistry :
public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>(); public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>();
public bool[] PartAvailable = Array.Empty<bool>(); public bool[] PartAvailable = Array.Empty<bool>();
public uint CellId; public uint CellId;
public ulong LifetimeVersion;
} }
private readonly Dictionary<uint, PoseRecord> _poses = new(); private readonly Dictionary<uint, PoseRecord> _poses = new();
private ulong _nextLifetimeVersion;
public event Action<uint>? EffectPoseChanged; public event Action<uint>? EffectPoseChanged;
@ -68,7 +71,7 @@ public sealed class EntityEffectPoseRegistry :
bool changed; bool changed;
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record)) if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
{ {
record = new PoseRecord(); record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
_poses.Add(entity.Id, record); _poses.Add(entity.Id, record);
changed = true; changed = true;
} }
@ -126,7 +129,7 @@ public sealed class EntityEffectPoseRegistry :
bool changed; bool changed;
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record)) if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
{ {
record = new PoseRecord(); record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
_poses.Add(localEntityId, record); _poses.Add(localEntityId, record);
changed = true; changed = true;
} }
@ -176,7 +179,9 @@ public sealed class EntityEffectPoseRegistry :
uint[] removedOwners = _poses.Keys.ToArray(); uint[] removedOwners = _poses.Keys.ToArray();
_poses.Clear(); _poses.Clear();
foreach (uint owner in removedOwners) foreach (uint owner in removedOwners)
{
EffectPoseChanged?.Invoke(owner); EffectPoseChanged?.Invoke(owner);
}
} }
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
@ -248,6 +253,19 @@ public sealed class EntityEffectPoseRegistry :
return false; 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( private static bool CopyParts(
PoseRecord record, PoseRecord record,
IReadOnlyList<Matrix4x4> partLocal, IReadOnlyList<Matrix4x4> partLocal,
@ -277,3 +295,8 @@ public sealed class EntityEffectPoseRegistry :
return changed; return changed;
} }
} }
public interface IEntityEffectPoseLifetimeSource
{
ulong GetPoseOwnerLifetimeVersion(uint localEntityId);
}

View file

@ -12,7 +12,10 @@ public sealed record ScriptActivationInfo(
uint ScriptId, uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms, IReadOnlyList<Matrix4x4> PartTransforms,
EntityEffectProfile? EffectProfile = null, EntityEffectProfile? EffectProfile = null,
IReadOnlyList<bool>? PartAvailability = null); IReadOnlyList<bool>? PartAvailability = null,
DatReaderWriter.DBObjs.Setup? Setup = null,
uint DefaultAnimationId = 0,
bool UsesStaticAnimationWorkset = false);
/// <summary> /// <summary>
/// Owns create-time Setup script activation and its symmetric teardown for /// Owns create-time Setup script activation and its symmetric teardown for
@ -25,13 +28,29 @@ public sealed record ScriptActivationInfo(
/// </remarks> /// </remarks>
public sealed class EntityScriptActivator 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 PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink; private readonly ParticleHookSink _particleSink;
private readonly EntityEffectPoseRegistry _poses; private readonly EntityEffectPoseRegistry _poses;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver; private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner; private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
private readonly Action<uint>? _unregisterDatStaticEffectOwner; private readonly Action<uint>? _unregisterDatStaticEffectOwner;
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new(); private readonly Action<WorldEntity, ScriptActivationInfo>? _registerDatStaticAnimationOwner;
private readonly Action<uint>? _unregisterDatStaticAnimationOwner;
private readonly Dictionary<uint, StaticOwnerState> _staticOwners = new();
public EntityScriptActivator( public EntityScriptActivator(
PhysicsScriptRunner scriptRunner, PhysicsScriptRunner scriptRunner,
@ -39,7 +58,9 @@ public sealed class EntityScriptActivator
EntityEffectPoseRegistry poses, EntityEffectPoseRegistry poses,
Func<WorldEntity, ScriptActivationInfo?> resolver, Func<WorldEntity, ScriptActivationInfo?> resolver,
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null, Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
Action<uint>? unregisterDatStaticEffectOwner = null) Action<uint>? unregisterDatStaticEffectOwner = null,
Action<WorldEntity, ScriptActivationInfo>? registerDatStaticAnimationOwner = null,
Action<uint>? unregisterDatStaticAnimationOwner = null)
{ {
_scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner)); _scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink)); _particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
@ -47,6 +68,8 @@ public sealed class EntityScriptActivator
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_registerDatStaticEffectOwner = registerDatStaticEffectOwner; _registerDatStaticEffectOwner = registerDatStaticEffectOwner;
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner; _unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
_registerDatStaticAnimationOwner = registerDatStaticAnimationOwner;
_unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner;
} }
public void OnCreate(WorldEntity entity) public void OnCreate(WorldEntity entity)
@ -57,33 +80,93 @@ public sealed class EntityScriptActivator
return; return;
if (entity.ServerGuid == 0 if (entity.ServerGuid == 0
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing)) && _staticOwners.TryGetValue(key, out StaticOwnerState? retained))
{ {
if (ReferenceEquals(existing, entity)) if (!ReferenceEquals(retained.Entity, entity))
return; {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique."); $"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); ScriptActivationInfo? info = _resolver(entity);
if (info is null) if (info is null)
return; 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) if (entity.ServerGuid == 0)
_activeStaticOwners.Add(key, entity); {
var state = new StaticOwnerState
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile) {
_registerDatStaticEffectOwner?.Invoke(key, entity, profile); 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) if (info.ScriptId != 0)
_scriptRunner.Play(info.ScriptId, key, entity.Position); _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) public void OnRemove(WorldEntity entity)
{ {
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
@ -91,17 +174,48 @@ public sealed class EntityScriptActivator
if (key == 0) if (key == 0)
return; return;
if (entity.ServerGuid == 0 if (entity.ServerGuid == 0)
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|| !ReferenceEquals(existing, entity)))
{ {
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; return;
} }
_unregisterDatStaticAnimationOwner?.Invoke(key);
_scriptRunner.StopAllForEntity(key); _scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false); _particleSink.StopAllForEntity(key, fadeOut: false);
_poses.Remove(key); _poses.Remove(key);
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
_unregisterDatStaticEffectOwner?.Invoke(key);
} }
} }

View file

@ -31,6 +31,7 @@ public sealed class LiveEntityPresentationController : IDisposable
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new(); private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new(); private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new(); private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
private readonly HashSet<LiveEntityRecord> _drainingRecords = new();
private bool _disposed; private bool _disposed;
public LiveEntityPresentationController( public LiveEntityPresentationController(
@ -69,8 +70,20 @@ public sealed class LiveEntityPresentationController : IDisposable
} }
_readyGenerationByGuid[serverGuid] = record.Generation; _readyGenerationByGuid[serverGuid] = record.Generation;
ApplyPendingTransitions(record); if (!ApplyPendingTransitions(record)
return true; || 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);
} }
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary> /// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
@ -83,8 +96,7 @@ public sealed class LiveEntityPresentationController : IDisposable
return false; return false;
} }
ApplyPendingTransitions(record); return ApplyPendingTransitions(record);
return true;
} }
/// <summary> /// <summary>
@ -188,6 +200,7 @@ public sealed class LiveEntityPresentationController : IDisposable
{ {
_activePlacementGenerationByGuid.Remove(record.ServerGuid); _activePlacementGenerationByGuid.Remove(record.ServerGuid);
} }
_drainingRecords.Remove(record);
} }
public void Clear() public void Clear()
@ -195,6 +208,7 @@ public sealed class LiveEntityPresentationController : IDisposable
_readyGenerationByGuid.Clear(); _readyGenerationByGuid.Clear();
_suspendedShadowGenerationByGuid.Clear(); _suspendedShadowGenerationByGuid.Clear();
_activePlacementGenerationByGuid.Clear(); _activePlacementGenerationByGuid.Clear();
_drainingRecords.Clear();
} }
public void Dispose() public void Dispose()
@ -206,46 +220,83 @@ public sealed class LiveEntityPresentationController : IDisposable
Clear(); 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) while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
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)
{ {
case RetailHiddenTransition.BecameHidden: if (record.WorldEntity is not { } entity
_playTyped(entity.Id, HiddenScriptType, 1f); || !IsCurrent(record, entity))
_setDirectChildrenNoDraw(record.ServerGuid, true); {
_shadows.Suspend(entity.Id); return false;
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;
case RetailHiddenTransition.BecameVisible: // set_state writes the final state before any PartArray/cell side
_playTyped(entity.Id, UnHideScriptType, 1f); // effect. Retained shadow registrations must see those same bits
_setDirectChildrenNoDraw(record.ServerGuid, false); // even while their cell rows are suspended.
// Retail invokes the same PartArray boundary before _shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
// CObjCell::unhide_object restores cell visibility.
_handlePartArrayEnterWorld(entity.Id); switch (transition.HiddenTransition)
if (!IsPlacementActive(record) && RestoreShadow(record, entity)) {
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid); case RetailHiddenTransition.BecameHidden:
break; _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) private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{ {
if (!visible if (record.WorldEntity is not { } entity
|| record.WorldEntity is not { } entity || !IsCurrent(record, entity))
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0 {
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) || IsPlacementActive(record)
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration) || !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|| readyGeneration != record.Generation || readyGeneration != record.Generation
@ -287,10 +354,40 @@ public sealed class LiveEntityPresentationController : IDisposable
return; return;
} }
if (RestoreShadow(record, entity)) bool restored = RestoreShadow(record, entity);
if (!IsCurrent(record, entity))
return;
if (restored)
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid); _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) => private bool IsPlacementActive(LiveEntityRecord record) =>
_activePlacementGenerationByGuid.TryGetValue( _activePlacementGenerationByGuid.TryGetValue(
record.ServerGuid, record.ServerGuid,

View file

@ -48,6 +48,7 @@ public interface ILiveEntityRemotePlacementRuntime :
Vector3 LastServerPosition { get; set; } Vector3 LastServerPosition { get; set; }
double LastServerPositionTime { get; set; } double LastServerPositionTime { get; set; }
Vector3 LastShadowSyncPosition { get; set; } Vector3 LastShadowSyncPosition { get; set; }
Quaternion LastShadowSyncOrientation { get; set; }
void HitGround(); void HitGround();
void LeaveGround(); void LeaveGround();
} }
@ -111,6 +112,13 @@ public sealed class LiveEntityRecord
ServerGuid = snapshot.Guid; ServerGuid = snapshot.Guid;
Snapshot = snapshot; Snapshot = snapshot;
RefreshDerivedState(); 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; FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
ApplyRawPhysicsState(RawPhysicsState); ApplyRawPhysicsState(RawPhysicsState);
} }
@ -124,7 +132,29 @@ public sealed class LiveEntityRecord
public uint CanonicalLandblockId { get; internal set; } public uint CanonicalLandblockId { get; internal set; }
public uint RawPhysicsState { get; internal set; } public uint RawPhysicsState { get; internal set; }
public PhysicsStateFlags FinalPhysicsState { get; internal set; } public PhysicsStateFlags FinalPhysicsState { get; internal set; }
/// <summary>
/// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner. It
/// survives animation and MovementManager attachment/replacement, spatial
/// rebucketing, and appearance rehydration, and dies with this record.
/// </summary>
public RetailObjectQuantumClock ObjectClock { get; } = new();
/// <summary>
/// Changes whenever this record leaves or re-enters the ordinary-object
/// workset. Unlike <see cref="ProjectionMutationVersion"/>, 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.
/// </summary>
internal ulong ObjectClockEpoch { get; private set; }
/// <summary>
/// True after this incarnation has successfully constructed its retail
/// <c>CPartArray</c>. 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.
/// </summary>
public bool HasPartArray { get; internal set; }
public PhysicsBody? PhysicsBody { get; internal set; } public PhysicsBody? PhysicsBody { get; internal set; }
internal bool PhysicsBodyAcquisitionInProgress { get; set; }
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
internal bool RequiresRemotePlacementRuntime { get; set; } internal bool RequiresRemotePlacementRuntime { get; set; }
@ -134,6 +164,16 @@ public sealed class LiveEntityRecord
public bool IsSpatiallyProjected { get; internal set; } public bool IsSpatiallyProjected { get; internal set; }
public bool IsSpatiallyVisible { get; internal set; } public bool IsSpatiallyVisible { get; internal set; }
internal ulong ProjectionMutationVersion { get; set; } internal ulong ProjectionMutationVersion { get; set; }
/// <summary>
/// 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.
/// </summary>
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 bool WorldSpawnPublished { get; internal set; }
public LiveEntityProjectionKind ProjectionKind { get; internal set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; }
internal bool RuntimeComponentsTeardownCompleted { get; set; } internal bool RuntimeComponentsTeardownCompleted { get; set; }
@ -145,8 +185,21 @@ public sealed class LiveEntityRecord
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) => internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
_pendingStateTransitions.TryDequeue(out 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) internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
{ {
StateAuthorityVersion++;
RawPhysicsState = rawState; RawPhysicsState = rawState;
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply( RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
FinalPhysicsState, FinalPhysicsState,
@ -158,6 +211,33 @@ public sealed class LiveEntityRecord
return transition; 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) internal void SetChildNoDraw(bool noDraw)
{ {
FinalPhysicsState = noDraw FinalPhysicsState = noDraw
@ -241,6 +321,10 @@ public sealed class LiveEntityRuntime
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _spatialAnimationsByLocalId = new(); private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _spatialAnimationsByLocalId = new();
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _spatialRemoteMotionByGuid = new(); private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _spatialRemoteMotionByGuid = new();
private readonly Dictionary<uint, ILiveEntityProjectileRuntime> _spatialProjectilesByGuid = new(); private readonly Dictionary<uint, ILiveEntityProjectileRuntime> _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<uint, LiveEntityRecord> _spatialRootObjectsByGuid = new();
private bool _isClearing; private bool _isClearing;
private bool _sessionClearPendingFinalization; private bool _sessionClearPendingFinalization;
private bool _isRegisteringResources; private bool _isRegisteringResources;
@ -297,6 +381,8 @@ public sealed class LiveEntityRuntime
_spatialRemoteMotionByGuid; _spatialRemoteMotionByGuid;
internal IReadOnlyDictionary<uint, ILiveEntityProjectileRuntime> SpatialProjectileRuntimes => internal IReadOnlyDictionary<uint, ILiveEntityProjectileRuntime> SpatialProjectileRuntimes =>
_spatialProjectilesByGuid; _spatialProjectilesByGuid;
internal IReadOnlyDictionary<uint, LiveEntityRecord> SpatialRootObjects =>
_spatialRootObjectsByGuid;
public ParentAttachmentState ParentAttachments { get; } = new(); public ParentAttachmentState ParentAttachments { get; } = new();
/// <summary> /// <summary>
@ -327,6 +413,7 @@ public sealed class LiveEntityRuntime
{ {
retained.Snapshot = result.Snapshot; retained.Snapshot = result.Snapshot;
retained.RefreshDerivedState(); retained.RefreshDerivedState();
retained.AdvanceCreateAuthority();
RefreshSpatialRuntimeIndexes(retained); RefreshSpatialRuntimeIndexes(retained);
return new LiveEntityRegistrationResult(result, retained, false, false); return new LiveEntityRegistrationResult(result, retained, false, false);
} }
@ -510,8 +597,18 @@ public sealed class LiveEntityRuntime
record.WorldEntity!.IsAncestorDrawVisible = true; record.WorldEntity!.IsAncestorDrawVisible = true;
} }
RefreshPresentation(record); RefreshPresentation(record);
RebucketLiveEntity(serverGuid, fullCellId); WorldEntity materialized = record.WorldEntity!;
return 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;
} }
/// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary> /// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary>
@ -523,6 +620,10 @@ public sealed class LiveEntityRuntime
bool wasProjected = record.IsSpatiallyProjected; bool wasProjected = record.IsSpatiallyProjected;
bool wasVisible = record.IsSpatiallyVisible; bool wasVisible = record.IsSpatiallyVisible;
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
serverGuid,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
ulong projectionOperation = ++record.ProjectionMutationVersion; ulong projectionOperation = ++record.ProjectionMutationVersion;
// GpuWorldState reports an intermediate false/true pair while moving // GpuWorldState reports an intermediate false/true pair while moving
// between two loaded buckets. Suppress those implementation details // between two loaded buckets. Suppress those implementation details
@ -570,6 +671,38 @@ public sealed class LiveEntityRuntime
record.CanonicalLandblockId = spatialCellOrLandblockId == 0 record.CanonicalLandblockId = spatialCellOrLandblockId == 0
? 0u ? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; : (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); RefreshSpatialRuntimeIndexes(record);
Exception? runtimeNotificationFailure = null; Exception? runtimeNotificationFailure = null;
if (!wasProjected || wasVisible != visible) if (!wasProjected || wasVisible != visible)
@ -583,6 +716,18 @@ public sealed class LiveEntityRuntime
runtimeNotificationFailure = error; 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( ThrowAfterCommittedProjectionChange(
serverGuid, serverGuid,
spatialNotificationFailure, spatialNotificationFailure,
@ -600,6 +745,11 @@ public sealed class LiveEntityRuntime
|| record.WorldEntity is null) || record.WorldEntity is null)
return false; return false;
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
serverGuid,
out LiveEntityRecord? rootRecord)
&& ReferenceEquals(rootRecord, record);
ulong objectClockEpoch = record.ObjectClockEpoch;
ulong projectionOperation = ++record.ProjectionMutationVersion; ulong projectionOperation = ++record.ProjectionMutationVersion;
Exception? spatialNotificationFailure = null; Exception? spatialNotificationFailure = null;
try try
@ -631,6 +781,14 @@ public sealed class LiveEntityRuntime
_visibleWorldEntitiesByGuid.Remove(serverGuid); _visibleWorldEntitiesByGuid.Remove(serverGuid);
record.IsSpatiallyProjected = false; record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = 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); RefreshSpatialRuntimeIndexes(record);
Exception? runtimeNotificationFailure = null; Exception? runtimeNotificationFailure = null;
if (spatialEdgeWasDeferred) if (spatialEdgeWasDeferred)
@ -644,6 +802,18 @@ public sealed class LiveEntityRuntime
runtimeNotificationFailure = error; 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( ThrowAfterCommittedProjectionChange(
serverGuid, serverGuid,
spatialNotificationFailure, spatialNotificationFailure,
@ -845,11 +1015,70 @@ public sealed class LiveEntityRuntime
return true; return true;
} }
/// <summary>
/// Acquires the one retail <c>CPhysicsObj</c> 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.
/// </summary>
internal PhysicsBody GetOrCreatePhysicsBody(
uint serverGuid,
Func<LiveEntityRecord, PhysicsBody> 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) public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime)
{ {
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); 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; PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody)) && !ReferenceEquals(canonicalBody, candidateBody))
@ -868,6 +1097,7 @@ public sealed class LiveEntityRuntime
record.RemoteMotionRuntime = runtime; record.RemoteMotionRuntime = runtime;
record.PhysicsBody = candidateBody; record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState; candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
{ {
// Capture the incarnation, not only its GUID. A callback retained // Capture the incarnation, not only its GUID. A callback retained
@ -919,6 +1149,9 @@ public sealed class LiveEntityRuntime
throw new InvalidOperationException( throw new InvalidOperationException(
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized."); $"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; PhysicsBody candidateBody = runtime.Body;
if (record.PhysicsBody is { } canonicalBody if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody)) && !ReferenceEquals(canonicalBody, candidateBody))
@ -930,6 +1163,7 @@ public sealed class LiveEntityRuntime
record.ProjectileRuntime = runtime; record.ProjectileRuntime = runtime;
record.PhysicsBody = candidateBody; record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState; candidateBody.State = record.FinalPhysicsState;
SynchronizePhysicsBodyActiveState(record);
RefreshSpatialRuntimeIndexes(record); RefreshSpatialRuntimeIndexes(record);
} }
@ -999,6 +1233,8 @@ public sealed class LiveEntityRuntime
if (applied) if (applied)
{ {
RefreshRecord(update.Guid, accepted); RefreshRecord(update.Guid, accepted);
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
ClearWorldCell(update.Guid); ClearWorldCell(update.Guid);
ParentAttachments.EndChildProjection(update.Guid); ParentAttachments.EndChildProjection(update.Guid);
} }
@ -1011,6 +1247,8 @@ public sealed class LiveEntityRuntime
if (applied) if (applied)
{ {
RefreshRecord(update.ChildGuid, accepted); RefreshRecord(update.ChildGuid, accepted);
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
ClearWorldCell(update.ChildGuid); ClearWorldCell(update.ChildGuid);
} }
return applied; return applied;
@ -1022,6 +1260,8 @@ public sealed class LiveEntityRuntime
if (applied) if (applied)
{ {
RefreshRecord(update.ChildGuid, accepted); RefreshRecord(update.ChildGuid, accepted);
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
ClearWorldCell(update.ChildGuid); ClearWorldCell(update.ChildGuid);
} }
return applied; return applied;
@ -1036,13 +1276,24 @@ public sealed class LiveEntityRuntime
bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps); bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
RefreshRecord(update.Guid, snapshot); RefreshRecord(update.Guid, snapshot);
if (applied
&& retainPayload
&& _recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
{
record.AdvanceMovementAuthority();
}
return applied; return applied;
} }
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted) public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
{ {
bool applied = _inbound.TryApplyVector(update, out 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; return applied;
} }
@ -1117,6 +1368,13 @@ public sealed class LiveEntityRuntime
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition); RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
if (acceptedPosition) if (acceptedPosition)
{ {
if (_recordsByGuid.TryGetValue(
update.Guid,
out LiveEntityRecord? acceptedRecord)
&& ReferenceEquals(acceptedRecord, beforePosition))
{
acceptedRecord.AdvancePositionAuthority();
}
ParentAttachments.EndChildProjection(update.Guid); ParentAttachments.EndChildProjection(update.Guid);
} }
} }
@ -1136,12 +1394,231 @@ public sealed class LiveEntityRuntime
/// script/effect owners but clears the cell and withdraws the root /// script/effect owners but clears the cell and withdraws the root
/// projection until a fresh Position re-enters it. /// projection until a fresh Position re-enters it.
/// </summary> /// </summary>
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) => public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) GetRootObjectClockDisposition(serverGuid)
&& record.IsSpatiallyProjected is RetailObjectClockDisposition.Advance;
&& record.IsSpatiallyVisible
&& record.FullCellId != 0 internal bool IsCurrentPositionAuthority(
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0; 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;
/// <summary>
/// Copies the canonical loaded root-object workset used by retail
/// <c>CPhysics::UseTime</c>. The record reference is the incarnation token;
/// callers must revalidate it after callbacks that can delete or rebucket.
/// </summary>
internal void CopySpatialRootObjectRecordsTo(List<LiveEntityRecord> 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);
/// <summary>
/// Copies the currently spatial animation owners through the concrete
/// dictionary enumerator. This keeps per-frame traversal allocation-free;
/// exposing the workset as <see cref="IReadOnlyDictionary{TKey,TValue}"/>
/// would box its enumerator at a hot call site.
/// </summary>
internal void CopySpatialAnimationRuntimesTo<TAnimation>(
List<KeyValuePair<uint, TAnimation>> 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<uint, TAnimation>(localId, typed));
}
}
}
internal void CopySpatialAnimationLocalIdsTo(HashSet<uint> destination)
{
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
foreach (uint localId in _spatialAnimationsByLocalId.Keys)
destination.Add(localId);
}
/// <summary>
/// Host side of retail <c>CPhysicsObj::set_active(1)</c> for a movement
/// manager owned by this exact incarnation. Static is a required no-op.
/// </summary>
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;
}
/// <summary>
/// Commits retail <c>CPhysicsObj::set_velocity</c> (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.
/// </summary>
internal bool TryCommitAuthoritativeVelocity(
LiveEntityRecord record,
PhysicsBody body,
Vector3 velocity,
double currentTime) =>
TryCommitAuthoritativeVectorCore(
record,
body,
velocity,
angularVelocity: null,
currentTime);
/// <summary>
/// Commits the paired retail VectorUpdate writes
/// (<c>set_velocity</c>, then <c>set_omega</c>) to one canonical body.
/// Validation is atomic: malformed wire vectors cannot partially mutate
/// the live object.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Copies the currently spatial remote-motion owners into caller-owned /// 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)) if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
return; 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.FullCellId = 0;
record.CanonicalLandblockId = 0; record.CanonicalLandblockId = 0;
RefreshSpatialRuntimeIndexes(record); RefreshSpatialRuntimeIndexes(record);
@ -1404,6 +1894,7 @@ public sealed class LiveEntityRuntime
private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) => private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) =>
record.WorldEntity is not null record.WorldEntity is not null
&& record.ProjectionKind is LiveEntityProjectionKind.World
&& record.IsSpatiallyProjected && record.IsSpatiallyProjected
&& record.IsSpatiallyVisible && record.IsSpatiallyVisible
&& record.FullCellId != 0; && record.FullCellId != 0;
@ -1413,6 +1904,17 @@ public sealed class LiveEntityRuntime
bool current = IsCurrentRecord(record); bool current = IsCurrentRecord(record);
bool spatial = current && HasSpatialRuntimeProjection(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 (record.WorldEntity is { } entity)
{ {
if (spatial && record.AnimationRuntime is { } animation) if (spatial && record.AnimationRuntime is { } animation)
@ -1455,6 +1957,12 @@ public sealed class LiveEntityRuntime
private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record) 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 if (record.WorldEntity is { } entity
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation) && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation)
&& ReferenceEquals(indexedAnimation, record.AnimationRuntime)) && ReferenceEquals(indexedAnimation, record.AnimationRuntime))
@ -1506,13 +2014,46 @@ public sealed class LiveEntityRuntime
return; return;
bool wasVisible = record.IsSpatiallyVisible; bool wasVisible = record.IsSpatiallyVisible;
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
serverGuid,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
record.IsSpatiallyVisible = visible; 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); RefreshSpatialRuntimeIndexes(record);
RefreshPresentation(record); RefreshPresentation(record);
if (_rebucketingGuid != serverGuid && wasVisible != visible) if (_rebucketingGuid != serverGuid && wasVisible != visible)
PublishProjectionVisibilityChanged(record, 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) private void PublishProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{ {
Delegate[] subscribers = ProjectionVisibilityChanged?.GetInvocationList() Delegate[] subscribers = ProjectionVisibilityChanged?.GetInvocationList()
@ -1617,6 +2158,7 @@ public sealed class LiveEntityRuntime
_spatialAnimationsByLocalId.Clear(); _spatialAnimationsByLocalId.Clear();
_spatialRemoteMotionByGuid.Clear(); _spatialRemoteMotionByGuid.Clear();
_spatialProjectilesByGuid.Clear(); _spatialProjectilesByGuid.Clear();
_spatialRootObjectsByGuid.Clear();
ParentAttachments.Clear(); ParentAttachments.Clear();
_inbound.Clear(); _inbound.Clear();
_spatial.ClearLiveEntityLifetimeState(); _spatial.ClearLiveEntityLifetimeState();

View file

@ -52,21 +52,25 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
&& runtime.ClearAnimationRuntime(guid); && runtime.ClearAnimationRuntime(guid);
} }
/// <summary>Copies only currently resident local IDs without boxing a
/// dictionary-key enumerator.</summary>
public void CopySpatialIdsTo(HashSet<uint> destination)
{
ArgumentNullException.ThrowIfNull(destination);
LiveEntityRuntime? runtime = _runtime();
if (runtime is null)
destination.Clear();
else
runtime.CopySpatialAnimationLocalIdsTo(destination);
}
public Enumerator GetEnumerator() public Enumerator GetEnumerator()
{ {
_iterationSnapshot.Clear();
LiveEntityRuntime? runtime = _runtime(); LiveEntityRuntime? runtime = _runtime();
if (runtime is not null) if (runtime is null)
{ _iterationSnapshot.Clear();
foreach (var pair in runtime.SpatialAnimationRuntimes) else
{ runtime.CopySpatialAnimationRuntimesTo(_iterationSnapshot);
if (pair.Value is TAnimation typed)
{
_iterationSnapshot.Add(
new KeyValuePair<uint, TAnimation>(pair.Key, typed));
}
}
}
return new Enumerator(runtime, _iterationSnapshot); return new Enumerator(runtime, _iterationSnapshot);
} }

View file

@ -0,0 +1,123 @@
namespace AcDream.App.World;
/// <summary>
/// Serializes retail network/event mutations on the update thread.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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, TState>(
TReceiver receiver,
TState state,
Action<TReceiver, TState> operation) : IPendingOperation
{
public void Invoke() => operation(receiver, state);
}
private readonly Queue<IPendingOperation> _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;
}
}
/// <summary>
/// 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.
/// </summary>
internal void Run<TReceiver, TState>(
TReceiver receiver,
TState state,
Action<TReceiver, TState> operation)
{
ArgumentNullException.ThrowIfNull(operation);
if (_isDraining)
{
_pending.Enqueue(
new PendingStateOperation<TReceiver, TState>(
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();
}
}

View file

@ -142,15 +142,16 @@ public sealed class AnimationSequencer
/// <para> /// <para>
/// Crucially this is **not** per-node: while a link animation plays, the /// Crucially this is **not** per-node: while a link animation plays, the
/// surfaced velocity is still the cycle's velocity (the cycle was added /// surfaced velocity is still the cycle's velocity (the cycle was added
/// last, so SetVelocity's latest call wins). Remote entity dead-reckoning /// last, so SetVelocity's latest call wins). <c>CSequence::apply_physics</c>
/// reads this to integrate position without gapping during stance /// contributes it directly to the complete root Frame; no separate body-
/// transitions. /// velocity reconstruction is performed from this accessor.
/// </para> /// </para>
/// </summary> /// </summary>
public Vector3 CurrentVelocity => _core.Velocity; public Vector3 CurrentVelocity => _core.Velocity;
/// <summary> /// <summary>
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics. /// Sequence-wide omega. <c>CSequence::apply_physics</c> rotates the same
/// complete root Frame that carries PosFrames and sequence velocity.
/// </summary> /// </summary>
public Vector3 CurrentOmega => _core.Omega; public Vector3 CurrentOmega => _core.Omega;
@ -283,6 +284,12 @@ public sealed class AnimationSequencer
_table = new CMotionTable(motionTable); _table = new CMotionTable(motionTable);
_state = new MotionState(); _state = new MotionState();
_manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this)); _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);
} }
/// <summary> /// <summary>
@ -399,38 +406,9 @@ public sealed class AnimationSequencer
if (dispatchResult != MotionTableManagerError.Success) if (dispatchResult != MotionTableManagerError.Success)
return; return;
// ── Synthesize CurrentOmega for turn cycles ─────────────────────── // Rotation stays DAT-authored. Retail MotionData::add_motion
// Humanoid turn MotionData often ships without HasOmega. Until the // (0x005224B0) contributes the entry's literal omega to CSequence;
// remaining R6 rotation path consumes CSequence's complete Frame // CSequence::apply_physics emits it through the 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));
}
} }
/// <summary> /// <summary>
@ -453,6 +431,36 @@ public sealed class AnimationSequencer
/// </summary> /// </summary>
public void InitializeState() => EnsureInitialized(); public void InitializeState() => EnsureInitialized();
/// <summary>
/// Retail <c>CPartArray::InitDefaults</c> (0x00518980): a Setup
/// <c>DefaultAnimation</c> bypasses the MotionTable, clears the sequence,
/// and appends one direct animation over frames <c>0..-1</c> at 30 fps.
/// Installation is unconditional for every setup-backed PartArray.
/// <c>CPhysicsObj::InitDefaults</c> separately decides whether a Static
/// owner enters <c>CPhysics::static_animating_objects</c>; ordinary motion
/// table initialization may subsequently replace this direct sequence.
/// </summary>
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<Animation>)animationId,
LowFrame = 0,
HighFrame = -1,
Framerate = 30f,
});
return _core.CurrAnim is not null;
}
/// <summary> /// <summary>
/// R2-Q5: the single dispatch entry — lazy initialize_state, then /// R2-Q5: the single dispatch entry — lazy initialize_state, then
/// <see cref="MotionTableManager.PerformMovement"/>. The resulting /// <see cref="MotionTableManager.PerformMovement"/>. The resulting

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
@ -15,15 +16,9 @@ namespace AcDream.Core.Physics;
// InterpolationManager::NodeCompleted acclient @ 0x005559A0 // InterpolationManager::NodeCompleted acclient @ 0x005559A0
// InterpolationManager::StopInterpolating acclient @ 0x00555950 // InterpolationManager::StopInterpolating acclient @ 0x00555950
// //
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes // FIFO Position-waypoint queue (cap 20). The compatibility overload returns
// current body position + max-speed from the motion table; we return the // only its world-space origin, while the production overload carries retail's
// world-space delta vector to apply to the body for this frame. // complete relative Frame from Position::subtract2, including orientation.
//
// 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.
// //
// Bug fixes applied vs prior port (audit § 7): // Bug fixes applied vs prior port (audit § 7):
// #1: progress_quantum accumulates dt (not step magnitude). // #1: progress_quantum accumulates dt (not step magnitude).
@ -38,8 +33,7 @@ namespace AcDream.Core.Physics;
internal sealed class InterpolationNode internal sealed class InterpolationNode
{ {
public Vector3 TargetPosition; public Vector3 TargetPosition;
public float Heading; public Quaternion TargetOrientation = Quaternion.Identity;
public bool IsHeadingValid;
} }
/// <summary> /// <summary>
@ -120,6 +114,7 @@ public sealed class InterpolationManager
private float _progressQuantum = 0f; // progress_quantum (sum of dt) private float _progressQuantum = 0f; // progress_quantum (sum of dt)
private float _originalDistance = OriginalDistanceSentinel; // original_distance private float _originalDistance = OriginalDistanceSentinel; // original_distance
private int _failCount = 0; // node_fail_counter private int _failCount = 0; // node_fail_counter
private bool _keepHeading; // keep_heading
// ── public API ──────────────────────────────────────────────────────────── // ── public API ────────────────────────────────────────────────────────────
@ -167,11 +162,31 @@ public sealed class InterpolationManager
/// Pass <c>null</c> if not available — far/near classification falls back /// Pass <c>null</c> if not available — far/near classification falls back
/// to "near" (no pre-armed blip). /// to "near" (no pre-armed blip).
/// </param> /// </param>
public void Enqueue( public Quaternion? Enqueue(
Vector3 targetPosition, Vector3 targetPosition,
float heading, float heading,
bool isMovingTo, bool isMovingTo,
Vector3? currentBodyPosition = null) Vector3? currentBodyPosition = null)
=> Enqueue(
targetPosition,
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, heading),
isMovingTo,
currentBodyPosition,
currentBodyOrientation: null);
/// <summary>
/// Complete-frame overload of retail <c>InterpolateTo</c>. The node keeps
/// the target quaternion; a near enqueue assigns
/// <paramref name="isMovingTo"/> to retail's manager-wide
/// <c>keep_heading</c> flag. The far branch deliberately retains the
/// manager's prior flag, matching the retail early return.
/// </summary>
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 // Retail compares dist against either the tail's stored position
// (if tail exists AND tail->type == 1) or the body's m_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): // Far branch (retail line 352918, dist > GetAutonomyBlipDistance):
if (dist > AutonomyBlipDistance) 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). // Pre-arm immediate blip on next AdjustOffset (audit § 7 #3).
_failCount = StallFailCountThreshold + 1; _failCount = StallFailCountThreshold + 1;
return; return null;
} }
// Near & already-close branch (retail line 352962): // Near & already-close branch (retail line 352962):
@ -209,7 +231,14 @@ public sealed class InterpolationManager
if (bodyDist <= DesiredDistance) if (bodyDist <= DesiredDistance)
{ {
Clear(); 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(); _queue.RemoveFirst();
// 3. Append. // 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 _queue.AddLast(new InterpolationNode
{ {
TargetPosition = target, TargetPosition = target,
Heading = heading, TargetOrientation = targetOrientation,
IsHeadingValid = isMovingTo,
}); });
} }
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));
}
/// <summary> /// <summary>
/// Compute the per-frame world-space correction delta. Combines the retail /// Compute the per-frame world-space correction delta. Combines the retail
/// <c>UseTime</c> blip-check (fail_count &gt; 3 → snap to tail, clear queue) /// <c>UseTime</c> blip-check (fail_count &gt; 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). /// Max motion-table speed for this entity's current cycle (m/s).
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback. /// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
/// </param> /// </param>
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;
}
/// <summary>
/// Retail <c>InterpolationManager::adjust_offset</c> complete-Frame path
/// (0x00555D30). When interpolation is active it replaces both components
/// of <paramref name="offset"/> with <c>Position::subtract2</c>'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.
/// </summary>
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. // dt sanity guard — protects PhysicsBody.Position from NaN poisoning.
if (dt <= 0 || double.IsNaN(dt)) if (dt <= 0 || double.IsNaN(dt))
return Vector3.Zero; return default;
if (_queue.First is null) if (_queue.First is null)
return Vector3.Zero; return default;
// Distance to head node (retail line 353083). // Distance to head node (retail line 353083).
var head = _queue.First.Value; var head = _queue.First.Value;
@ -278,7 +391,7 @@ public sealed class InterpolationManager
if (dist <= DesiredDistance) if (dist <= DesiredDistance)
{ {
NodeCompleted(popHead: true, currentBodyPosition); NodeCompleted(popHead: true, currentBodyPosition);
return Vector3.Zero; return default;
} }
// Catch-up speed (retail line 353122 + 353128 fallback). // 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. // Retail splits this into a separate UseTime call; we collapse it.
if (_failCount > StallFailCountThreshold) if (_failCount > StallFailCountThreshold)
{ {
Vector3 tailPos = _queue.Last!.Value.TargetPosition; InterpolationNode tail = _queue.Last!.Value;
Vector3 tailDelta = tail.TargetPosition - currentBodyPosition;
Clear(); Clear();
return tailPos - currentBodyPosition; return new InterpolationStep(
true,
tailDelta,
tail.TargetOrientation);
} }
// Per-frame step magnitude (retail line 353218). // Per-frame step magnitude (retail line 353218).
@ -358,9 +475,17 @@ public sealed class InterpolationManager
// Direction × step. // Direction × step.
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * 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);
/// <summary> /// <summary>
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached; /// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
/// popHead=false during stall fail (re-baseline only). For our collapsed /// popHead=false during stall fail (re-baseline only). For our collapsed

View file

@ -25,6 +25,48 @@ public static class FrameOps
/// square against |v|².</summary> /// square against |v|².</summary>
public const float FEpsilon = 0.000199999995f; public const float FEpsilon = 0.000199999995f;
/// <summary>
/// Retail <c>Frame::set_rotate</c> (0x00535080). The candidate is
/// normalized in extended precision, then the complete frame is checked
/// by <c>Frame::IsValid</c> (0x00534ED0). If that check fails, retail
/// restores the previous quaternion instead of allowing NaNs to poison
/// the live object transform.
/// </summary>
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;
}
/// <summary><c>Frame::grotate</c> — incremental WORLD-space rotation.</summary> /// <summary><c>Frame::grotate</c> — incremental WORLD-space rotation.</summary>
public static void GRotate(Frame frame, Vector3 rotationGlobal) public static void GRotate(Frame frame, Vector3 rotationGlobal)
{ {
@ -48,7 +90,10 @@ public static class FrameOps
rotationGlobal.Y * s * invMag, rotationGlobal.Y * s * invMag,
rotationGlobal.Z * s * invMag, rotationGlobal.Z * s * invMag,
c); c);
frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation)); frame.Orientation = SetRotate(
frame.Origin,
frame.Orientation,
Quaternion.Multiply(r, frame.Orientation));
} }
/// <summary><c>Frame::rotate</c> — LOCAL rotation vector, mapped to /// <summary><c>Frame::rotate</c> — LOCAL rotation vector, mapped to
@ -65,7 +110,10 @@ public static class FrameOps
public static void Combine(Frame frame, Frame pos) public static void Combine(Frame frame, Frame pos)
{ {
frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation); 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);
} }
/// <summary> /// <summary>
@ -75,7 +123,9 @@ public static class FrameOps
/// </summary> /// </summary>
public static void Subtract1(Frame frame, Frame pos) 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.Orientation * Quaternion.Conjugate(pos.Orientation));
frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation); frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
} }

View file

@ -15,10 +15,10 @@ namespace AcDream.Core.Physics.Motion;
/// ///
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated /// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
/// position delta, in the mover's LOCAL frame after /// position delta, in the mover's LOCAL frame after
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the /// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> is the full
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a /// relative quaternion carried by retail <c>Frame</c>; the heading accessors
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/> /// are narrow helpers for managers that explicitly call
/// (P5 convention, degrees).</para> /// <c>Frame::set_heading</c>.</para>
/// </summary> /// </summary>
public sealed class MotionDeltaFrame public sealed class MotionDeltaFrame
{ {
@ -26,10 +26,50 @@ public sealed class MotionDeltaFrame
/// delta (mover-local frame).</summary> /// delta (mover-local frame).</summary>
public Vector3 Origin; public Vector3 Origin;
/// <summary>Retail <c>Frame</c> rotation — carries the /// <summary>Retail <c>Frame</c>'s complete relative rotation.</summary>
/// <c>Frame::set_heading</c> output.</summary>
public Quaternion Orientation = Quaternion.Identity; public Quaternion Orientation = Quaternion.Identity;
/// <summary>
/// Restore retail's identity <c>Frame</c>: zero translation and identity
/// orientation. Object ticks reuse these accumulators to avoid allocating
/// one transform per entity per render frame.
/// </summary>
public void Reset()
{
Origin = Vector3.Zero;
Orientation = Quaternion.Identity;
}
/// <summary>
/// Retail <c>Frame::combine</c> (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 <c>m_scale</c> operation performed by
/// <c>CPhysicsObj::UpdatePositionInternal</c>.
/// </summary>
public void Combine(
Vector3 localOrigin,
Quaternion localOrientation,
float originScale = 1f)
{
Origin += Vector3.Transform(localOrigin * originScale, Orientation);
Orientation = FrameOps.SetRotate(
Origin,
Orientation,
Orientation * localOrientation);
}
/// <summary>
/// Compose another complete delta frame using retail
/// <c>Frame::combine</c> semantics.
/// </summary>
public void Combine(MotionDeltaFrame delta, float originScale = 1f)
{
ArgumentNullException.ThrowIfNull(delta);
Combine(delta.Origin, delta.Orientation, originScale);
}
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary> /// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
public float GetHeading() => MoveToMath.GetHeading(Orientation); public float GetHeading() => MoveToMath.GetHeading(Orientation);

View file

@ -20,52 +20,25 @@ namespace AcDream.Core.Physics.Motion;
/// <c>combine_motion</c>/<c>re_modify</c> — the composition the retired /// <c>combine_motion</c>/<c>re_modify</c> — the composition the retired
/// AP-73 row approximated with one cycle. /// AP-73 row approximated with one cycle.
/// </para> /// </para>
///
/// <para>
/// The <see cref="TurnApplied"/>/<see cref="TurnStopped"/> 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
/// <c>apply_physics</c> chain — R6 scope; register row). They fire BEFORE
/// the dispatch so a consumer sees the seed even if the dat lacks the
/// modifier entry.
/// </para>
/// </summary> /// </summary>
public sealed class MotionTableDispatchSink : IInterpretedMotionSink public sealed class MotionTableDispatchSink : IInterpretedMotionSink
{ {
private readonly AnimationSequencer _sequencer; private readonly AnimationSequencer _sequencer;
/// <summary>Turn-class dispatch observed: (motion, signedSpeed) —
/// TurnLeft arrives either as the explicit 0x0E command or as
/// TurnRight + negative speed (adjust_motion wire convention).</summary>
public Action<uint, float>? TurnApplied { get; set; }
/// <summary>Turn-class stop observed.</summary>
public Action? TurnStopped { get; set; }
public MotionTableDispatchSink(AnimationSequencer sequencer) public MotionTableDispatchSink(AnimationSequencer sequencer)
{ {
ArgumentNullException.ThrowIfNull(sequencer); ArgumentNullException.ThrowIfNull(sequencer);
_sequencer = 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) public bool ApplyMotion(uint motion, float speed)
{ {
if (IsTurn(motion))
TurnApplied?.Invoke(motion, speed);
uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
return result == MotionTableManagerError.Success; return result == MotionTableManagerError.Success;
} }
public bool StopMotion(uint motion) public bool StopMotion(uint motion)
{ {
if (IsTurn(motion))
TurnStopped?.Invoke();
uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f)); uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
return result == MotionTableManagerError.Success; return result == MotionTableManagerError.Success;
} }
@ -75,14 +48,10 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink
/// (0x00518890): <c>MotionTableManager::PerformMovement(type 5)</c>. /// (0x00518890): <c>MotionTableManager::PerformMovement(type 5)</c>.
/// The manager stops everything and queues its Ready-sentinel /// The manager stops everything and queues its Ready-sentinel
/// pending_animations entry UNCONDITIONALLY — the completable partner /// pending_animations entry UNCONDITIONALLY — the completable partner
/// of the interp's A9 pending_motions node. A full stop ends any turn /// of the interp's A9 pending_motions node.
/// cycle, so the ObservedOmega seam is notified like
/// <see cref="StopMotion"/>'s turn-class branch.
/// </summary> /// </summary>
public bool StopCompletely() public bool StopCompletely()
{ {
TurnStopped?.Invoke();
uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely()); uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely());
return result == MotionTableManagerError.Success; return result == MotionTableManagerError.Success;
} }

View file

@ -114,12 +114,8 @@ public static class MoveToMath
/// <summary> /// <summary>
/// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) — /// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) —
/// extracts the compass heading (P5 convention) from a body orientation /// extracts the compass heading (P5 convention) from a body orientation
/// quaternion. <b>The packer-reuse trap (V0-pins §P5 correction):</b> /// quaternion. This method uses the scalar bridge derived from acdream's
/// acdream's outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is /// body convention
/// wire-correct at the QUATERNION level but its internal scalar
/// intermediate (<c>headingDeg = 180 - yawDeg</c>) is holtburger's
/// SHIFTED convention, not retail's. This method uses the CORRECT
/// scalar bridge derived from acdream's own body convention
/// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation = /// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation =
/// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X): /// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X):
/// world-forward = <c>(cos Yaw, sin Yaw)</c>, so /// world-forward = <c>(cos Yaw, sin Yaw)</c>, so
@ -166,11 +162,10 @@ public static class MoveToMath
} }
/// <summary> /// <summary>
/// R4-V5: the scalar leg of <see cref="GetHeading"/> for bodies whose /// R4-V5: scalar projection of <see cref="GetHeading"/>. The local
/// authoritative facing is a yaw ANGLE rather than a quaternion (the /// player's <c>Yaw</c> property exposes this horizontal projection while
/// local player: <c>PlayerMovementController.Yaw</c>, radians, Yaw=0 /// its complete body quaternion remains authoritative. Same P5 bridge:
/// faces +X, re-synced into the body quaternion every Update). Same P5 /// <c>heading = (90 - yawDeg) mod 360</c>.
/// bridge: <c>heading = (90 - yawDeg) mod 360</c>.
/// </summary> /// </summary>
public static float HeadingFromYaw(float yawRad) public static float HeadingFromYaw(float yawRad)
{ {
@ -181,10 +176,10 @@ public static class MoveToMath
} }
/// <summary> /// <summary>
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the /// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the scalar
/// <c>set_heading</c> seam for yaw-authoritative bodies (the local /// bridge used by an explicit <c>Frame::set_heading</c> operation. This
/// player's heading snap must write <c>Yaw</c>, NOT the body /// operation deliberately replaces pitch/roll; ordinary frame composition
/// quaternion, which the controller re-derives from Yaw every frame). /// does not pass through this projection.
/// Returns radians wrapped to [-π, π] matching the controller's own /// Returns radians wrapped to [-π, π] matching the controller's own
/// wrap discipline. /// wrap discipline.
/// </summary> /// </summary>

View file

@ -46,11 +46,11 @@ namespace AcDream.Core.Physics.Motion;
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet — /// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para> /// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
/// ///
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b> /// <para><b>Activation.</b> Retail begins
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active /// <c>MovementManager::PerformMovement</c> (0x005240D0, call at 0x005240D9)
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade /// with an unconditional <c>CPhysicsObj::set_active(1)</c>, before validating
/// route never re-asserted it — status quo preserved (zero-behavior-change /// the movement type. <see cref="ActivatePhysicsObject"/> is the host seam for
/// slice), not a new deviation.</para> /// that call. Static objects keep retail's no-op behavior in the host.</para>
/// </summary> /// </summary>
public sealed class MovementManager public sealed class MovementManager
{ {
@ -73,6 +73,14 @@ public sealed class MovementManager
/// <c>CPhysicsObj</c>.</summary> /// <c>CPhysicsObj</c>.</summary>
public Func<MoveToManager>? MoveToFactory { get; set; } public Func<MoveToManager>? MoveToFactory { get; set; }
/// <summary>
/// Host seam for retail's unconditional
/// <c>CPhysicsObj::set_active(1)</c> at the head of
/// <see cref="PerformMovement"/>. It is deliberately invoked even for an
/// invalid movement type.
/// </summary>
public Action? ActivatePhysicsObject { get; set; }
public MovementManager(MotionInterpreter minterp) public MovementManager(MotionInterpreter minterp)
{ {
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp)); Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
@ -102,6 +110,8 @@ public sealed class MovementManager
/// </summary> /// </summary>
public WeenieError PerformMovement(MovementStruct mvs) public WeenieError PerformMovement(MovementStruct mvs)
{ {
ActivatePhysicsObject?.Invoke();
switch (mvs.Type) switch (mvs.Type)
{ {
case MovementType.RawCommand: case MovementType.RawCommand:

View file

@ -774,7 +774,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// is set). Register row: releases a "stuck to object" sticky-manager /// is set). Register row: releases a "stuck to object" sticky-manager
/// attachment — R5 wires the real StickyManager; until then this is an /// attachment — R5 wires the real StickyManager; until then this is an
/// optional callback the App layer may bind, matching the existing /// optional callback the App layer may bind, matching the existing
/// <c>Action?</c> seam convention (see <c>MotionTableDispatchSink.TurnStopped</c>). /// <c>Action?</c> seams on this runtime owner.
/// </summary> /// </summary>
public Action? UnstickFromObject { get; set; } public Action? UnstickFromObject { get; set; }

View file

@ -59,7 +59,7 @@ public static class PhysicsObjUpdate
/// must therefore observe any velocity change made by the movement /// must therefore observe any velocity change made by the movement
/// callback; moving that callback after reflection changes the result. /// callback; moving that callback after reflection changes the result.
/// </remarks> /// </remarks>
public static void CommitSetPositionTransition( public static bool CommitSetPositionTransition(
PhysicsBody body, PhysicsBody body,
bool inContact, bool inContact,
bool onWalkable, bool onWalkable,
@ -68,7 +68,9 @@ public static class PhysicsObjUpdate
bool previousContact, bool previousContact,
bool previousOnWalkable, bool previousOnWalkable,
Action? hitGround = null, Action? hitGround = null,
Action? leaveGround = null) Action? leaveGround = null,
Func<bool>? isCurrent = null,
Func<bool>? isVelocityCurrent = null)
{ {
ArgumentNullException.ThrowIfNull(body); ArgumentNullException.ThrowIfNull(body);
@ -95,11 +97,26 @@ public static class PhysicsObjUpdate
body.TransientState &= ~TransientStateFlags.OnWalkable; body.TransientState &= ~TransientStateFlags.OnWalkable;
if (!previousOnWalkable && finalOnWalkable) if (!previousOnWalkable && finalOnWalkable)
{
hitGround?.Invoke(); hitGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
else if (previousOnWalkable && !finalOnWalkable) else if (previousOnWalkable && !finalOnWalkable)
{
leaveGround?.Invoke(); leaveGround?.Invoke();
if (isCurrent?.Invoke() == false)
return false;
}
body.calc_acceleration(); 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( HandleAllCollisions(
body, body,
collisionNormalValid, collisionNormalValid,
@ -107,6 +124,7 @@ public static class PhysicsObjUpdate
previousContact, previousContact,
previousOnWalkable, previousOnWalkable,
finalOnWalkable); finalOnWalkable);
return isCurrent?.Invoke() ?? true;
} }
/// <summary> /// <summary>

View file

@ -42,6 +42,39 @@ public readonly record struct ProjectileAdvanceResult(
Vector3 CollisionNormal, Vector3 CollisionNormal,
bool TransitionOk); bool TransitionOk);
/// <summary>
/// Candidate frame produced by UpdatePhysicsInternal and held across retail's
/// process_hooks slot before the outer transition/collision commit.
/// </summary>
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);
/// <summary>
/// Mutable kinematic fields produced by the candidate integration. The split
/// App scheduler holds these transactionally across <c>process_hooks</c> so a
/// re-entrant authoritative correction never inherits an abandoned impulse.
/// </summary>
public readonly record struct ProjectileQuantumDynamics(
Vector3 Velocity,
Vector3 CachedVelocity,
Vector3 Acceleration,
Vector3 Omega,
TransientStateFlags TransientState,
double LastUpdateTime);
/// <summary> /// <summary>
/// Core-only port of the retail live-projectile physics driver. It owns no /// Core-only port of the retail live-projectile physics driver. It owns no
/// renderer, network, or world-lifetime state: the App controller supplies a /// renderer, network, or world-lifetime state: the App controller supplies a
@ -152,32 +185,84 @@ public sealed class ProjectilePhysicsStepper
transitionOk); transitionOk);
} }
private void StepQuantum( /// <summary>
/// Advances exactly one quantum already admitted by the owning
/// <see cref="RetailObjectQuantumClock"/>. 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.
/// </summary>
public ProjectileAdvanceResult AdvanceQuantum(
PhysicsBody body, PhysicsBody body,
float dt, float quantum,
ref uint cellId, uint cellId,
ProjectileCollisionSphere sphere, ProjectileCollisionSphere sphere,
uint movingEntityId, uint movingEntityId,
uint designatedTargetId, uint designatedTargetId = 0,
ref bool collisionNormalValid, bool isParented = false)
ref Vector3 collisionNormal,
ref bool transitionOk)
{ {
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; Vector3 beginPosition = body.Position;
Quaternion beginOrientation = body.Orientation; Quaternion beginOrientation = body.Orientation;
Position beginCellPosition = body.CellPosition;
bool beginInWorld = body.InWorld;
ProjectileQuantumDynamics beginDynamics = CaptureDynamics(body);
bool previousContact = body.InContact; bool previousContact = body.InContact;
bool previousOnWalkable = body.OnWalkable; 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.calc_acceleration();
body.UpdatePhysicsInternal(dt); body.UpdatePhysicsInternal(quantum);
Vector3 candidatePosition = body.Position; Vector3 candidatePosition = body.Position;
Quaternion candidateOrientation = body.Orientation; Quaternion candidateOrientation = body.Orientation;
Vector3 displacement = candidatePosition - beginPosition; Vector3 displacement = candidatePosition - beginPosition;
bool candidateMoved = displacement != Vector3.Zero; bool candidateMoved = displacement != Vector3.Zero;
if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath)) if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath))
{ {
candidateOrientation = RetailFrameMath.SetVectorHeading( candidateOrientation = RetailFrameMath.SetVectorHeading(
@ -189,76 +274,218 @@ public sealed class ProjectilePhysicsStepper
{ {
body.CachedVelocity = Vector3.Zero; body.CachedVelocity = Vector3.Zero;
body.Orientation = candidateOrientation; 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. ProjectileQuantumDynamics candidateDynamics = CaptureDynamics(body);
// Restore the authoritative start frame before transition setup so RestoreBeginFrame(
// carried cell-relative position and self-shadow identity remain exact. body,
body.Position = beginPosition; beginPosition,
body.Orientation = beginOrientation; 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 moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped)
? ObjectInfoState.PathClipped ? ObjectInfoState.PathClipped
: ObjectInfoState.None; : ObjectInfoState.None;
var resolved = _physics.ResolveWithTransition( var resolved = _physics.ResolveWithTransition(
beginPosition, preparation.BeginPosition,
candidatePosition, preparation.CandidatePosition,
cellId, cellId,
sphereRadius: sphere.Radius, sphereRadius: sphere.Radius,
sphereHeight: 0f, sphereHeight: 0f,
stepUpHeight: PhysicsGlobals.DefaultStepHeight, stepUpHeight: PhysicsGlobals.DefaultStepHeight,
stepDownHeight: 0f, stepDownHeight: 0f,
isOnGround: previousOnWalkable, isOnGround: preparation.PreviousOnWalkable,
body: body, body: body,
moverFlags: moverFlags, moverFlags: moverFlags,
movingEntityId: movingEntityId, movingEntityId: movingEntityId,
localSphereOrigin: sphere.LocalOrigin, localSphereOrigin: sphere.LocalOrigin,
beginOrientation: beginOrientation, beginOrientation: preparation.BeginOrientation,
endOrientation: candidateOrientation, endOrientation: preparation.CandidateOrientation,
designatedTargetId: designatedTargetId); designatedTargetId: designatedTargetId);
bool collisionNormalValid = false;
Vector3 collisionNormal = default;
bool transitionOk = resolved.Ok;
if (!resolved.Ok) if (!resolved.Ok)
{ {
// UpdateObjectInternal 0x005156B0: when transition() returns null, body.SetFrameInCurrentCell(
// retail calls set_frame(candidate), zeros cached_velocity, and preparation.CandidatePosition,
// deliberately skips SetPositionInternal/handle_all_collisions. preparation.CandidateOrientation);
// 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.CachedVelocity = Vector3.Zero; body.CachedVelocity = Vector3.Zero;
transitionOk = false;
return;
} }
else
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)
{ {
collisionNormalValid = true; body.CachedVelocity = (resolved.Position - preparation.BeginPosition)
collisionNormal = resolved.CollisionNormal; / 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;
} }
} }

View file

@ -1,4 +1,5 @@
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
@ -7,10 +8,10 @@ namespace AcDream.Core.Physics;
/// by <c>CSequence::update</c> + InterpolationManager catch-up correction. /// by <c>CSequence::update</c> + InterpolationManager catch-up correction.
/// Pure function — no side effects or hidden state. /// Pure function — no side effects or hidden state.
/// ///
/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730): /// Mirrors retail <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512C30):
/// rootOffset = CPartArray::Update(dt) // animation /// CPartArray writes one complete local Frame, then PositionManager mutates
/// PositionManager::adjust_offset(rootOffset) // adds correction /// that same Frame. Active interpolation replaces it with
/// frame.origin += rootOffset /// <c>Position::subtract2</c>; later managers receive the result.
/// ///
/// The animation root motion is the complete body-local <c>Frame.Origin</c> /// The animation root motion is the complete body-local <c>Frame.Origin</c>
/// accumulated by <c>CPartArray::Update</c>: authored PosFrames plus the /// accumulated by <c>CPartArray::Update</c>: authored PosFrames plus the
@ -30,6 +31,50 @@ namespace AcDream.Core.Physics;
/// </summary> /// </summary>
public sealed class RemoteMotionCombiner public sealed class RemoteMotionCombiner
{ {
/// <summary>
/// Compose retail's complete per-object delta frame. Interpolation, when
/// active, replaces the PartArray frame via
/// <c>Position::subtract2</c>; otherwise the authored root frame remains.
/// </summary>
/// <returns><c>true</c> when interpolation replaced the root frame.</returns>
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;
}
/// <summary> /// <summary>
/// Compute the per-frame world-space delta to add to body.Position. /// Compute the per-frame world-space delta to add to body.Position.
/// </summary> /// </summary>
@ -90,11 +135,21 @@ public sealed class RemoteMotionCombiner
// AdjustOffset returns Vector3.Zero in two cases mapped to retail's // AdjustOffset returns Vector3.Zero in two cases mapped to retail's
// early-return: empty queue OR distance < DesiredDistance (0.05m). // early-return: empty queue OR distance < DesiredDistance (0.05m).
// In both, body falls back to animation root motion. // In both, body falls back to animation root motion.
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed); var root = new MotionDeltaFrame
if (correction.LengthSquared() > 0f) {
return correction; Origin = rootMotionLocalDelta,
};
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocalDelta, ori); 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 // Slope projection (queue-empty fallback only). Locomotion cycles
// bake Z=0 in body-local, so without projection the body's Z stays // bake Z=0 in body-local, so without projection the body's Z stays

View file

@ -0,0 +1,101 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Ports the lifecycle and 96-world-unit Active gate at the head of retail
/// <c>CPhysicsObj::update_object</c> (0x00515D10) plus the inactive branch of
/// <c>UpdateObjectInternal</c> (0x005156B0).
/// </summary>
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,
}

View file

@ -12,6 +12,40 @@ namespace AcDream.Core.Physics;
/// </remarks> /// </remarks>
public static class RetailObjectManagerTail public static class RetailObjectManagerTail
{ {
/// <summary>
/// Allocation-free production overload for the concrete retail manager
/// owners. DetectionManager is not ported, so its ordered slot is empty.
/// </summary>
public static void Run(
Motion.TargetManager? target,
Motion.MovementManager? movement,
Motion.MotionTableManager? partArray,
Motion.PositionManager? position)
{
target?.HandleTargetting();
movement?.UseTime();
partArray?.UseTime();
position?.UseTime();
}
/// <summary>
/// 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.
/// </summary>
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( public static void Run(
Action? checkDetection, Action? checkDetection,
Action? handleTargeting, Action? handleTargeting,

View file

@ -0,0 +1,122 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Allocation-free port of <c>CPhysicsObj::update_object</c>
/// (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.
/// </summary>
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);
}
/// <summary>
/// Retail <c>set_active(0)</c>: suppress the full object path without
/// advancing or rebasing <c>update_time</c>.
/// </summary>
public void Deactivate() => IsActive = false;
/// <summary>
/// Retail <c>set_active(1)</c>. An inactive-to-active edge rebases
/// <c>update_time</c> to the current timer, so the reactivation frame does
/// not catch up suppressed time.
/// </summary>
/// <returns>True only when this call performed the reactivation edge.</returns>
public bool Activate()
{
if (IsActive)
return false;
IsActive = true;
_pending = 0.0;
return true;
}
public void Reset() => _pending = 0.0;
/// <summary>
/// Retail <c>CPhysicsObj::prepare_to_enter_world</c> (0x00511FA0): rebase
/// <c>update_time</c> 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.
/// </summary>
public void ResetForEnterWorld(bool isStatic = false)
{
_pending = 0.0;
if (!isStatic)
IsActive = true;
}
private const double FrameEpsilon = 0.000199999995;
}
/// <summary>
/// How the owning live-object lifecycle treats this render frame before
/// entering retail <c>CPhysicsObj::update_object</c>.
/// </summary>
public enum RetailObjectClockDisposition
{
Advance,
Suspend,
}
/// <summary>Compact result of one retail object-clock admission pass.</summary>
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;
}
}

View file

@ -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<MeshRef>(),
};
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<MeshRef>(),
};
var order = new List<string>();
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<MeshRef>(),
};
bool visible = false;
var order = new List<string>();
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);
}
}

View file

@ -38,7 +38,12 @@ public sealed class PlayerMouseLookMovementTests
float yawBefore = controller.Yaw; float yawBefore = controller.Yaw;
controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput()); 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(MotionCommand.TurnRight, turning.TurnCommand);
Assert.Equal(0.8f, turning.TurnSpeed); Assert.Equal(0.8f, turning.TurnSpeed);

View file

@ -50,7 +50,10 @@ public sealed class RetailLocalPlayerFrameControllerTests
}, },
() => calls.Add("spatial")); () => 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.True(local.TryGetPresentationAfterNetwork(out var presentation));
Assert.Equal( Assert.Equal(
@ -150,6 +153,119 @@ public sealed class RetailLocalPlayerFrameControllerTests
Assert.Equal(controller.Position, positionSeenByTargetManager); 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() private static PlayerMovementController CreateController()
{ {
var engine = new PhysicsEngine(); var engine = new PhysicsEngine();
@ -167,8 +283,10 @@ public sealed class RetailLocalPlayerFrameControllerTests
worldOffsetX: 0f, worldOffsetX: 0f,
worldOffsetY: 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); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
Assert.True(clock.IsActive);
return controller; return controller;
} }
} }

View file

@ -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<MeshRef>(),
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<MeshRef>(),
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<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
engine.AddLandblock(
0xAAB4FFFFu,
FlatTerrain(),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
192f,
0f);
return engine;
}
private static LoadedLandblock EmptyLandblock(uint landblockId) =>
new(
landblockId,
new DatReaderWriter.DBObjs.LandBlock(),
Array.Empty<WorldEntity>());
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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
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;
}
}

View file

@ -23,13 +23,12 @@ public sealed class PlayerOutboundPositionTests
0xA9B20021u, 0xA9B20021u,
new Vector3(116.07f, 22.56f, 83.76f)); new Vector3(116.07f, 22.56f, 83.76f));
Assert.True(controller.TryGetOutboundPosition( Assert.True(controller.TryGetOutboundPosition(out Position outbound));
Quaternion.Identity, Assert.Equal(0xA9B20021u, outbound.ObjCellId);
out uint cellId, Assert.Equal(
out Vector3 localOrigin)); new Vector3(116.07f, 22.56f, 83.76f),
Assert.Equal(0xA9B20021u, cellId); outbound.Frame.Origin);
Assert.Equal(new Vector3(116.07f, 22.56f, 83.76f), localOrigin); Assert.NotEqual(controller.Position, outbound.Frame.Origin);
Assert.NotEqual(controller.Position, localOrigin);
} }
[Fact] [Fact]
@ -37,10 +36,30 @@ public sealed class PlayerOutboundPositionTests
{ {
var controller = new PlayerMovementController(new PhysicsEngine()); var controller = new PlayerMovementController(new PhysicsEngine());
Assert.False(controller.TryGetOutboundPosition( Assert.False(controller.TryGetOutboundPosition(out _));
Quaternion.Identity, }
out _,
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] [Fact]

View file

@ -77,19 +77,19 @@ public sealed class ProjectileControllerTests
| PhysicsStateFlags.Hidden | PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions; | PhysicsStateFlags.IgnoreCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 1.1, 1, 1)); record, record.FinalPhysicsState, 1.1, 1, 1));
Vector3 hiddenPosition = entity.Position; Vector3 hiddenPosition = entity.Position;
fixture.Controller.Tick(5.0, 1, 1, playerWorldPosition: null); fixture.Controller.Tick(5.0, 1, 1, playerWorldPosition: null);
Assert.Equal(hiddenPosition, entity.Position); Assert.Equal(hiddenPosition, entity.Position);
Assert.True(body.InWorld); Assert.True(body.InWorld);
Assert.True(body.IsActive); 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); Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( 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); fixture.Controller.Tick(5.1, 1, 1, playerWorldPosition: null);
Assert.True(entity.Position.X > hiddenPosition.X); Assert.True(entity.Position.X > hiddenPosition.X);
@ -116,7 +116,7 @@ public sealed class ProjectileControllerTests
| PhysicsStateFlags.Hidden | PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions; | PhysicsStateFlags.IgnoreCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 1.1, 1, 1)); record, record.FinalPhysicsState, 1.1, 1, 1));
remote.Interp.Enqueue( remote.Interp.Enqueue(
entity.Position + Vector3.UnitX, entity.Position + Vector3.UnitX,
heading: 0f, heading: 0f,
@ -148,14 +148,27 @@ public sealed class ProjectileControllerTests
var fixture = new Fixture(loadInitialLandblock: true); var fixture = new Fixture(loadInitialLandblock: true);
LiveEntityRecord record = fixture.Spawn(instance: 1); LiveEntityRecord record = fixture.Spawn(instance: 1);
WorldEntity entity = record.WorldEntity!; 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)); Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
Assert.True(fixture.Controller.ApplyAuthoritativePosition( Assert.True(fixture.Controller.ApplyAuthoritativePosition(
Guid, record,
worldPosition: new Vector3(202f, 10f, 5f), worldPosition: new Vector3(10f, 202f, 5f),
cellLocalPosition: new Vector3(10f, 10f, 5f), cellLocalPosition: new Vector3(10f, 10f, 5f),
orientation: Quaternion.Identity, orientation: Quaternion.Identity,
velocity: new Vector3(1f, 2f, 3f),
fullCellId: CellB, fullCellId: CellB,
currentTime: 2.1, currentTime: 2.1,
liveCenterX: 1, liveCenterX: 1,
@ -177,24 +190,36 @@ public sealed class ProjectileControllerTests
Assert.False(runtime.Body.IsActive); Assert.False(runtime.Body.IsActive);
fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); 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( fixture.Controller.Tick(
2.5, 2.5,
liveCenterX: 1, liveCenterX: 1,
liveCenterY: 1, liveCenterY: 1,
playerWorldPosition: null); playerWorldPosition: pendingPosition);
Assert.True(record.IsSpatiallyVisible); Assert.True(record.IsSpatiallyVisible);
Assert.Same(entity, record.WorldEntity); Assert.Same(entity, record.WorldEntity);
Assert.Same(runtime, record.ProjectileRuntime); Assert.Same(runtime, record.ProjectileRuntime);
Assert.Equal(1, fixture.Resources.Registers); Assert.Equal(1, fixture.Resources.Registers);
Assert.Equal(pendingPosition, entity.Position); Vector3 firstReentryTick = entity.Position;
Assert.True(firstReentryTick.X > pendingPosition.X);
fixture.Controller.Tick( fixture.Controller.Tick(
2.6, 2.6,
liveCenterX: 1, liveCenterX: 1,
liveCenterY: 1, liveCenterY: 1,
playerWorldPosition: null); playerWorldPosition: pendingPosition);
Assert.True(entity.Position.X > pendingPosition.X); Assert.True(entity.Position.X > firstReentryTick.X);
} }
[Fact] [Fact]
@ -229,7 +254,15 @@ public sealed class ProjectileControllerTests
fixture.Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); fixture.Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Assert.Single(fixture.Live.SpatialProjectileRuntimes); 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.InWorld);
Assert.True(record.PhysicsBody.IsActive); Assert.True(record.PhysicsBody.IsActive);
@ -304,9 +337,10 @@ public sealed class ProjectileControllerTests
playerWorldPosition: null); playerWorldPosition: null);
Assert.Equal(0xAAB40001u, record.FullCellId); Assert.Equal(0xAAB40001u, record.FullCellId);
Assert.Equal(0xAAB40001u, record.PhysicsBody!.CellPosition.ObjCellId);
Assert.False(record.IsSpatiallyVisible); Assert.False(record.IsSpatiallyVisible);
Assert.True(record.IsSpatiallyProjected); Assert.True(record.IsSpatiallyProjected);
Assert.False(record.PhysicsBody!.InWorld); Assert.False(record.PhysicsBody.InWorld);
Assert.False(record.PhysicsBody.IsActive); Assert.False(record.PhysicsBody.IsActive);
Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(startCell)); Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(startCell));
Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u)); Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u));
@ -315,13 +349,14 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( 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.Spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu));
fixture.Controller.Tick(0.07, 0xA9, 0xB4, playerWorldPosition: null); fixture.Controller.Tick(0.07, 0xA9, 0xB4, playerWorldPosition: null);
Assert.True(record.IsSpatiallyVisible); Assert.True(record.IsSpatiallyVisible);
Assert.Equal(0xAAB40001u, record.PhysicsBody.CellPosition.ObjCellId);
Assert.True(record.PhysicsBody.InWorld); Assert.True(record.PhysicsBody.InWorld);
Assert.False(record.PhysicsBody.IsActive); Assert.True(record.PhysicsBody.IsActive);
Assert.Contains( Assert.Contains(
fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u), fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u),
entry => entry.EntityId == entity.Id); entry => entry.EntityId == entity.Id);
@ -336,7 +371,7 @@ public sealed class ProjectileControllerTests
PhysicsBody body = record.PhysicsBody!; PhysicsBody body = record.PhysicsBody!;
Assert.True(fixture.Controller.ApplyAuthoritativeVector( Assert.True(fixture.Controller.ApplyAuthoritativeVector(
Guid, record,
new Vector3(7f, 8f, 9f), new Vector3(7f, 8f, 9f),
new Vector3(0f, 0f, 2f), new Vector3(0f, 0f, 2f),
currentTime: 4.5)); currentTime: 4.5));
@ -344,11 +379,13 @@ public sealed class ProjectileControllerTests
Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega); Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega);
var correction = new Vector3(30f, 31f, 32f); var correction = new Vector3(30f, 31f, 32f);
var correctedVelocity = new Vector3(3f, 4f, 5f);
Assert.True(fixture.Controller.ApplyAuthoritativePosition( Assert.True(fixture.Controller.ApplyAuthoritativePosition(
Guid, record,
correction, correction,
new Vector3(30f, 31f, 32f), new Vector3(30f, 31f, 32f),
Quaternion.Identity, Quaternion.Identity,
correctedVelocity,
CellA, CellA,
currentTime: 5.0, currentTime: 5.0,
liveCenterX: 1, liveCenterX: 1,
@ -357,7 +394,95 @@ public sealed class ProjectileControllerTests
Assert.Same(body, record.PhysicsBody); Assert.Same(body, record.PhysicsBody);
Assert.Equal(correction, body.Position); Assert.Equal(correction, body.Position);
Assert.Equal(correction, record.WorldEntity!.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] [Fact]
@ -392,7 +517,7 @@ public sealed class ProjectileControllerTests
PhysicsBody body = runtime.Body; PhysicsBody body = runtime.Body;
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record,
PhysicsStateFlags.ReportCollisions, PhysicsStateFlags.ReportCollisions,
currentTime: 1.1, currentTime: 1.1,
liveCenterX: 1, liveCenterX: 1,
@ -411,7 +536,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record,
MissileState, MissileState,
currentTime: 1.3, currentTime: 1.3,
liveCenterX: 1, liveCenterX: 1,
@ -452,7 +577,7 @@ public sealed class ProjectileControllerTests
StateSequence: 2), StateSequence: 2),
out _)); out _));
Assert.True(fixture.Controller.ApplyAuthoritativeState( 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.Equal(destinationCell, record.FullCellId);
Assert.True(fixture.Live.TryApplyState( Assert.True(fixture.Live.TryApplyState(
new SetState.Parsed( new SetState.Parsed(
@ -462,7 +587,7 @@ public sealed class ProjectileControllerTests
StateSequence: 3), StateSequence: 3),
out _)); out _));
Assert.True(fixture.Controller.ApplyAuthoritativeState( 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, record.ProjectileRuntime);
Assert.Same(runtime.Body, record.PhysicsBody); Assert.Same(runtime.Body, record.PhysicsBody);
@ -481,7 +606,7 @@ public sealed class ProjectileControllerTests
body.LastUpdateTime = 1.0; body.LastUpdateTime = 1.0;
Assert.True(fixture.Controller.ApplyAuthoritativeVector( Assert.True(fixture.Controller.ApplyAuthoritativeVector(
Guid, record,
new Vector3(100f, 0f, 0f), new Vector3(100f, 0f, 0f),
Vector3.Zero, Vector3.Zero,
currentTime: 20.0)); currentTime: 20.0));
@ -506,7 +631,7 @@ public sealed class ProjectileControllerTests
body.TransientState &= ~TransientStateFlags.Active; body.TransientState &= ~TransientStateFlags.Active;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record,
MissileState | PhysicsStateFlags.Inelastic, MissileState | PhysicsStateFlags.Inelastic,
currentTime: 2.0, currentTime: 2.0,
liveCenterX: 1, liveCenterX: 1,
@ -516,11 +641,11 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 2.1, 1, 1)); record, record.FinalPhysicsState, 2.1, 1, 1));
Assert.False(body.IsActive); Assert.False(body.IsActive);
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, MissileState, 2.2, 1, 1)); record, MissileState, 2.2, 1, 1));
Assert.False(body.IsActive); Assert.False(body.IsActive);
} }
@ -533,10 +658,10 @@ public sealed class ProjectileControllerTests
PhysicsBody body = record.PhysicsBody!; PhysicsBody body = record.PhysicsBody!;
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 1.01, 1, 1)); record, record.FinalPhysicsState, 1.01, 1, 1));
Assert.True(fixture.Controller.ApplyAuthoritativeVector( Assert.True(fixture.Controller.ApplyAuthoritativeVector(
Guid, record,
new Vector3(100f, 0f, 0f), new Vector3(100f, 0f, 0f),
new Vector3(0f, 0f, 3f), new Vector3(0f, 0f, 3f),
1.02)); 1.02));
@ -561,11 +686,11 @@ public sealed class ProjectileControllerTests
fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body)); fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body));
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 2.0, 1, 1)); record, record.FinalPhysicsState, 2.0, 1, 1));
Vector3 before = body.Velocity; Vector3 before = body.Velocity;
Assert.False(fixture.Controller.ApplyAuthoritativeVector( Assert.False(fixture.Controller.ApplyAuthoritativeVector(
Guid, record,
new Vector3(0f, 0f, 12f), new Vector3(0f, 0f, 12f),
new Vector3(0f, 0f, 2f), new Vector3(0f, 0f, 2f),
3.0)); 3.0));
@ -586,14 +711,14 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 2.0, 1, 1)); record, record.FinalPhysicsState, 2.0, 1, 1));
body.TransientState |= TransientStateFlags.Active; body.TransientState |= TransientStateFlags.Active;
body.LastUpdateTime = 9.5; // generic remote interval used another clock owner body.LastUpdateTime = 9.5; // generic remote interval used another clock owner
body.set_velocity(new Vector3(40f, 0f, 0f)); body.set_velocity(new Vector3(40f, 0f, 0f));
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( 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.Equal(10.0, body.LastUpdateTime, 8);
Assert.True(body.IsActive); Assert.True(body.IsActive);
@ -611,13 +736,13 @@ public sealed class ProjectileControllerTests
PhysicsBody body = record.PhysicsBody!; PhysicsBody body = record.PhysicsBody!;
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 2.0, 1, 1)); record, record.FinalPhysicsState, 2.0, 1, 1));
PhysicsStateFlags stateBefore = body.State; PhysicsStateFlags stateBefore = body.State;
double clockBefore = body.LastUpdateTime; double clockBefore = body.LastUpdateTime;
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, MissileState, double.NaN, 1, 1)); record, MissileState, double.NaN, 1, 1));
Assert.Equal(record.FinalPhysicsState, body.State); Assert.Equal(record.FinalPhysicsState, body.State);
Assert.NotEqual(stateBefore, body.State); Assert.NotEqual(stateBefore, body.State);
@ -647,6 +772,41 @@ public sealed class ProjectileControllerTests
Assert.Equal(entity.Position, after.Translation); 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] [Fact]
public void Tick_PartArrayUsesRetailNinetySixUnitActivationBoundary() public void Tick_PartArrayUsesRetailNinetySixUnitActivationBoundary()
{ {
@ -699,17 +859,18 @@ public sealed class ProjectileControllerTests
Vector3 position = body.Position; Vector3 position = body.Position;
Assert.True(fixture.Controller.ApplyAuthoritativeVector( Assert.True(fixture.Controller.ApplyAuthoritativeVector(
Guid, record,
new Vector3(float.NaN, 0f, 0f), new Vector3(float.NaN, 0f, 0f),
Vector3.Zero, Vector3.Zero,
currentTime: 2.0)); currentTime: 2.0));
Assert.Equal(velocity, body.Velocity); Assert.Equal(velocity, body.Velocity);
Assert.True(fixture.Controller.ApplyAuthoritativePosition( Assert.True(fixture.Controller.ApplyAuthoritativePosition(
Guid, record,
new Vector3(float.PositiveInfinity, 0f, 0f), new Vector3(float.PositiveInfinity, 0f, 0f),
new Vector3(float.PositiveInfinity, 0f, 0f), new Vector3(float.PositiveInfinity, 0f, 0f),
Quaternion.Identity, Quaternion.Identity,
Vector3.Zero,
CellA, CellA,
currentTime: 2.1, currentTime: 2.1,
liveCenterX: 1, liveCenterX: 1,
@ -772,7 +933,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, MissileState, 2.0, 1, 1)); record, MissileState, 2.0, 1, 1));
Assert.NotNull(record.ProjectileRuntime); Assert.NotNull(record.ProjectileRuntime);
Assert.True(float.IsFinite(record.PhysicsBody!.Velocity.X)); Assert.True(float.IsFinite(record.PhysicsBody!.Velocity.X));
Assert.True(float.IsFinite(record.PhysicsBody.Velocity.Y)); Assert.True(float.IsFinite(record.PhysicsBody.Velocity.Y));
@ -793,7 +954,7 @@ public sealed class ProjectileControllerTests
PhysicsStateFlags.Gravity | PhysicsStateFlags.IgnoreCollisions; PhysicsStateFlags.Gravity | PhysicsStateFlags.IgnoreCollisions;
record.FinalPhysicsState = ordinaryState; record.FinalPhysicsState = ordinaryState;
Assert.False(fixture.Controller.ApplyAuthoritativeState( Assert.False(fixture.Controller.ApplyAuthoritativeState(
Guid, ordinaryState, 1.0, 1, 1)); record, ordinaryState, 1.0, 1, 1));
Assert.Equal(ordinaryState, remote.Body.State); Assert.Equal(ordinaryState, remote.Body.State);
Assert.Null(record.ProjectileRuntime); Assert.Null(record.ProjectileRuntime);
@ -803,7 +964,7 @@ public sealed class ProjectileControllerTests
ordinaryState | PhysicsStateFlags.Missile; ordinaryState | PhysicsStateFlags.Missile;
record.FinalPhysicsState = unsupportedMissile; record.FinalPhysicsState = unsupportedMissile;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, unsupportedMissile, 1.1, 1, 1)); record, unsupportedMissile, 1.1, 1, 1));
Assert.Equal(unsupportedMissile, remote.Body.State); Assert.Equal(unsupportedMissile, remote.Body.State);
Assert.Null(record.ProjectileRuntime); Assert.Null(record.ProjectileRuntime);
@ -819,7 +980,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, MissileState, double.NaN, 1, 1)); record, MissileState, double.NaN, 1, 1));
Assert.NotNull(record.ProjectileRuntime); Assert.NotNull(record.ProjectileRuntime);
Assert.Equal(MissileState, record.PhysicsBody!.State); Assert.Equal(MissileState, record.PhysicsBody!.State);
@ -827,7 +988,7 @@ public sealed class ProjectileControllerTests
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, MissileState, 0.1, 1, 1)); record, MissileState, 0.1, 1, 1));
Assert.Same(runtime, record.ProjectileRuntime); Assert.Same(runtime, record.ProjectileRuntime);
} }
@ -843,7 +1004,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, MissileState, double.PositiveInfinity, 1, 1)); record, MissileState, double.PositiveInfinity, 1, 1));
Assert.NotNull(record.ProjectileRuntime); Assert.NotNull(record.ProjectileRuntime);
Assert.Same(remote.Body, record.ProjectileRuntime!.Body); Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
@ -922,7 +1083,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record,
MissileState, MissileState,
currentTime: 1.2, currentTime: 1.2,
liveCenterX: 0xA9, liveCenterX: 0xA9,
@ -941,7 +1102,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState( 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, remote.CellId);
Assert.Equal(destinationCell, record.FullCellId); Assert.Equal(destinationCell, record.FullCellId);
@ -960,7 +1121,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( 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, record.FullCellId);
Assert.Equal(startCell, remote.CellId); Assert.Equal(startCell, remote.CellId);
Assert.Equal(startCell, body.CellPosition.ObjCellId); Assert.Equal(startCell, body.CellPosition.ObjCellId);
@ -986,7 +1147,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, MissileState, 1.0, 1, 1)); record, MissileState, 1.0, 1, 1));
Assert.NotNull(record.ProjectileRuntime); Assert.NotNull(record.ProjectileRuntime);
Assert.Same(remote.Body, record.ProjectileRuntime!.Body); Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
@ -1010,7 +1171,7 @@ public sealed class ProjectileControllerTests
record.FinalPhysicsState = MissileState; record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState( Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, MissileState, 1.0, 1, 1)); record, MissileState, 1.0, 1, 1));
Assert.Null(record.ProjectileRuntime); Assert.Null(record.ProjectileRuntime);
Assert.Same(remote.Body, record.PhysicsBody); Assert.Same(remote.Body, record.PhysicsBody);
@ -1113,15 +1274,22 @@ public sealed class ProjectileControllerTests
_ => throw new InvalidOperationException("re-entry recreated the entity"))); _ => throw new InvalidOperationException("re-entry recreated the entity")));
Assert.True(fixture.Controller.ApplyAuthoritativePosition( Assert.True(fixture.Controller.ApplyAuthoritativePosition(
Guid, record,
worldPosition: new Vector3(12f, 10f, 5f), worldPosition: new Vector3(12f, 10f, 5f),
cellLocalPosition: new Vector3(12f, 10f, 5f), cellLocalPosition: new Vector3(12f, 10f, 5f),
orientation: Quaternion.Identity, orientation: Quaternion.Identity,
velocity: Vector3.Zero,
fullCellId: CellA, fullCellId: CellA,
currentTime: 10.0, currentTime: 10.0,
liveCenterX: 1, liveCenterX: 1,
liveCenterY: 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( Assert.Contains(
fixture.Engine.ShadowObjects.GetObjectsInCell(CellA), fixture.Engine.ShadowObjects.GetObjectsInCell(CellA),
entry => entry.EntityId == entity.Id); entry => entry.EntityId == entity.Id);
@ -1130,8 +1298,14 @@ public sealed class ProjectileControllerTests
10.1, 10.1,
liveCenterX: 1, liveCenterX: 1,
liveCenterY: 1, liveCenterY: 1,
playerWorldPosition: null); playerWorldPosition: entity.Position);
Assert.Equal(13f, entity.Position.X, 3); 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] [Fact]
@ -1148,14 +1322,94 @@ public sealed class ProjectileControllerTests
isLocalPlayer: false)); isLocalPlayer: false));
LiveEntityRecord second = fixture.Spawn(instance: 8); LiveEntityRecord second = fixture.Spawn(instance: 8);
Assert.True(fixture.Controller.TryBind(second, ProjectileSetup(), 2.0)); 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(firstEntity, second.WorldEntity);
Assert.NotSame(firstRuntime, second.ProjectileRuntime); Assert.NotSame(firstRuntime, second.ProjectileRuntime);
Assert.Same(secondRuntime, second.ProjectileRuntime);
Assert.Equal(1, fixture.Controller.Count); Assert.Equal(1, fixture.Controller.Count);
Assert.Equal(2, fixture.Resources.Registers); Assert.Equal(2, fixture.Resources.Registers);
Assert.Equal(1, fixture.Resources.Unregisters); 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] [Fact]
public void MissingRetailSphere_FailsWithoutFabricatingCollisionBody() public void MissingRetailSphere_FailsWithoutFabricatingCollisionBody()
{ {
@ -1175,7 +1429,8 @@ public sealed class ProjectileControllerTests
{ {
internal Fixture( internal Fixture(
bool loadInitialLandblock = true, bool loadInitialLandblock = true,
PhysicsEngine? physicsEngine = null) PhysicsEngine? physicsEngine = null,
Action<WorldEntity>? publishRootPose = null)
{ {
if (loadInitialLandblock) if (loadInitialLandblock)
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
@ -1185,7 +1440,8 @@ public sealed class ProjectileControllerTests
Live, Live,
Engine, Engine,
_ => ResolvedSetup, _ => ResolvedSetup,
entity => Poses.UpdateRoot(entity)); publishRootPose ?? (entity => Poses.UpdateRoot(entity)),
() => LiveCenter);
} }
internal GpuWorldState Spatial { get; } = new(); internal GpuWorldState Spatial { get; } = new();
@ -1194,6 +1450,7 @@ public sealed class ProjectileControllerTests
internal PhysicsEngine Engine { get; } internal PhysicsEngine Engine { get; }
internal EntityEffectPoseRegistry Poses { get; } = new(); internal EntityEffectPoseRegistry Poses { get; } = new();
internal Setup ResolvedSetup { get; set; } = ProjectileSetup(); internal Setup ResolvedSetup { get; set; } = ProjectileSetup();
internal (int X, int Y) LiveCenter { get; set; } = (1, 1);
internal ProjectileController Controller { get; } internal ProjectileController Controller { get; }
internal LiveEntityRecord Spawn( internal LiveEntityRecord Spawn(
@ -1221,6 +1478,7 @@ public sealed class ProjectileControllerTests
elasticity); elasticity);
LiveEntityRegistrationResult registration = Live.RegisterLiveEntity(spawn); LiveEntityRegistrationResult registration = Live.RegisterLiveEntity(spawn);
Assert.NotNull(registration.Record); Assert.NotNull(registration.Record);
registration.Record!.HasPartArray = true;
WorldEntity? entity = Live.MaterializeLiveEntity( WorldEntity? entity = Live.MaterializeLiveEntity(
Guid, Guid,
cellId, cellId,
@ -1242,6 +1500,13 @@ public sealed class ProjectileControllerTests
} }
} }
public enum AuthoritativeMutation
{
Vector,
Position,
State,
}
private static Setup ProjectileSetup(float radius = 0.102f) => new() private static Setup ProjectileSetup(float radius = 0.102f) => new()
{ {
Spheres = Spheres =

View file

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

View file

@ -7,6 +7,7 @@ using AcDream.App.World;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -14,6 +15,296 @@ namespace AcDream.App.Tests.Physics;
public sealed class RemotePhysicsUpdaterTests 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<MeshRef>(),
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<MeshRef>(),
};
var animated = new GameWindow.AnimatedEntity
{
Entity = entity,
Setup = new Setup(),
Animation = new Animation(),
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = 1f,
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(),
PartAvailability = Array.Empty<bool>(),
};
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<MeshRef>(),
};
var animated = new GameWindow.AnimatedEntity
{
Entity = entity,
Setup = new Setup(),
Animation = new Animation(),
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = 1f,
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(),
PartAvailability = Array.Empty<bool>(),
};
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<MeshRef>(),
};
var animated = new GameWindow.AnimatedEntity
{
Entity = entity,
Setup = new Setup(),
Animation = new Animation(),
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = 1f,
PartTemplate = Array.Empty<GameWindow.AnimatedPartTemplate>(),
PartAvailability = Array.Empty<bool>(),
};
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] [Fact]
public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics() public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics()
{ {
@ -74,15 +365,55 @@ public sealed class RemotePhysicsUpdaterTests
Rotation = Quaternion.Identity, Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
int partArrayTailCalls = 0; AnimationSequencer sequencer = CreateSequencer();
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
updater.TickHidden( updater.TickHidden(
motion, motion,
entity, entity,
0.1f, 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<MeshRef>(),
};
AnimationSequencer sequencer = CreateSequencer();
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
var order = new List<string>();
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] [Fact]
@ -133,7 +464,7 @@ public sealed class RemotePhysicsUpdaterTests
} }
[Fact] [Fact]
public void TickHidden_LandingSynchronizesRemoteAirborneState() public void TickHidden_OutOfContactDoesNotInterpolateOrInventLanding()
{ {
var engine = BuildBoundaryEngine(); var engine = BuildBoundaryEngine();
var updater = new RemotePhysicsUpdater( var updater = new RemotePhysicsUpdater(
@ -165,9 +496,190 @@ public sealed class RemotePhysicsUpdaterTests
updater.TickHidden(motion, entity, 0.1f); updater.TickHidden(motion, entity, 0.1f);
Assert.True(motion.Body.InContact); Assert.False(motion.Body.InContact);
Assert.True(motion.Body.OnWalkable); Assert.False(motion.Body.OnWalkable);
Assert.False(motion.Airborne); 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] [Fact]
@ -192,6 +704,7 @@ public sealed class RemotePhysicsUpdaterTests
(_, _) => (0.48f, 1.835f), (_, _) => (0.48f, 1.835f),
(_, _, _, _) => { }); (_, _, _, _) => { });
var published = new List<uint>(); var published = new List<uint>();
var partPoseDirty = new List<uint>();
updater.TickHiddenEntities( updater.TickHiddenEntities(
live, live,
localPlayerServerGuid: 0x50000001u, localPlayerServerGuid: 0x50000001u,
@ -203,9 +716,11 @@ public sealed class RemotePhysicsUpdaterTests
Assert.True(live.UnregisterLiveEntity( Assert.True(live.UnregisterLiveEntity(
new DeleteObject.Parsed(other, InstanceSequence: 1), new DeleteObject.Parsed(other, InstanceSequence: 1),
isLocalPlayer: false)); isLocalPlayer: false));
}); },
markPartPoseDirty: partPoseDirty.Add);
Assert.Single(published); Assert.Single(published);
Assert.Equal(published, partPoseDirty);
Assert.Single(live.SpatialRemoteMotionRuntimes); Assert.Single(live.SpatialRemoteMotionRuntimes);
} }
@ -309,4 +824,208 @@ public sealed class RemotePhysicsUpdaterTests
currentBodyPosition: entity.Position); currentBodyPosition: entity.Position);
live.SetRemoteMotionRuntime(guid, remote); 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<WorldEntity>()));
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<WorldEntity>()));
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<LiveEntityRecord>(
registration.Record);
WorldEntity entity = Assert.IsType<WorldEntity>(
Live.MaterializeLiveEntity(
Guid,
SourceCell,
id => new WorldEntity
{
Id = id,
ServerGuid = Guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
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;
}
} }

View file

@ -44,6 +44,7 @@ public sealed class RemoteTeleportControllerTests
public Vector3 LastServerPosition { get; set; } public Vector3 LastServerPosition { get; set; }
public double LastServerPositionTime { get; set; } public double LastServerPositionTime { get; set; }
public Vector3 LastShadowSyncPosition { get; set; } public Vector3 LastShadowSyncPosition { get; set; }
public Quaternion LastShadowSyncOrientation { get; set; }
public void BindCanonicalCell(Func<uint> read, Action<uint> write) public void BindCanonicalCell(Func<uint> read, Action<uint> write)
{ {
_readCell = read; _readCell = read;
@ -64,6 +65,7 @@ public sealed class RemoteTeleportControllerTests
public Vector3 LastServerPosition { get; set; } public Vector3 LastServerPosition { get; set; }
public double LastServerPositionTime { get; set; } public double LastServerPositionTime { get; set; }
public Vector3 LastShadowSyncPosition { get; set; } public Vector3 LastShadowSyncPosition { get; set; }
public Quaternion LastShadowSyncOrientation { get; set; }
public void BindCanonicalCell(Func<uint> read, Action<uint> write) { } public void BindCanonicalCell(Func<uint> read, Action<uint> write) { }
public void HitGround() { } public void HitGround() { }
public void LeaveGround() { } public void LeaveGround() { }
@ -921,6 +923,165 @@ public sealed class RemoteTeleportControllerTests
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); 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] [Fact]
public void TeardownCallbackFailure_StillForgetsPendingBeforeGuidReuse() public void TeardownCallbackFailure_StillForgetsPendingBeforeGuidReuse()
{ {
@ -1009,6 +1170,75 @@ public sealed class RemoteTeleportControllerTests
return engine; 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<MeshRef>(),
})!;
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) => private static void AddDestination(PhysicsEngine engine) =>
engine.AddLandblock( engine.AddLandblock(
0xAAB4FFFFu, 0xAAB4FFFFu,

View file

@ -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<uint, LiveEntityAnimationSchedule> 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<uint, LiveEntityAnimationSchedule> 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<uint, LiveEntityAnimationSchedule> 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<uint, LiveEntityAnimationSchedule> 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<uint, LiveEntityAnimationSchedule> 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<uint, LiveEntityAnimationSchedule> 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<Vector3>();
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<uint, AnimationSequencer>? captureHooks = null,
Action<WorldEntity>? 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<WorldEntity>()));
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<MeshRef>(),
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<GameWindow.AnimatedPartTemplate>(),
PartAvailability = Array.Empty<bool>(),
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<WorldEntity>()));
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<MeshRef>(),
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<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
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;
}
}

View file

@ -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<uint>();
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<uint>();
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<PartTransform> 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<string>();
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<Animation>)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<Animation>)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<uint, Animation> _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;
}
}

View file

@ -119,6 +119,143 @@ public sealed class EntityEffectPoseRegistryTests
Assert.Equal(0, queue.Count); 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<Matrix4x4>());
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<Matrix4x4>());
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<Matrix4x4>());
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<Matrix4x4>());
};
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<Matrix4x4>());
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<Matrix4x4>());
};
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<Matrix4x4>());
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<Matrix4x4>());
});
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] [Fact]
public void AnimationHookQueue_CompletesMotionStateAtCaptureEvenWhenPoseWasRemoved() public void AnimationHookQueue_CompletesMotionStateAtCaptureEvenWhenPoseWasRemoved()
{ {
@ -217,6 +354,17 @@ public sealed class EntityEffectPoseRegistryTests
Calls.Add((entityId, entityWorldPosition)); 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 private sealed class NullAnimationLoader : IAnimationLoader
{ {
public Animation? LoadAnimation(uint animationId) => null; public Animation? LoadAnimation(uint animationId) => null;

View file

@ -260,6 +260,193 @@ public sealed class EntityScriptActivatorTests
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos); 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<Matrix4x4>(),
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<Matrix4x4>(),
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<Matrix4x4>(),
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<Matrix4x4>(),
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<InvalidOperationException>(() => activator.OnCreate(entity));
Assert.Equal(1, effectAttempts);
Assert.Equal(0, animationAttempts);
Assert.Equal(0, p.Runner.ActiveOwnerCount);
Assert.Throws<InvalidOperationException>(() => 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<Matrix4x4>(),
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<InvalidOperationException>(() => activator.OnRemove(entity));
Assert.Equal(1, animationReleaseAttempts);
Assert.Equal(0, effectReleaseAttempts);
Assert.Throws<InvalidOperationException>(() => 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] [Fact]
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary() public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
{ {

View file

@ -797,6 +797,7 @@ public sealed class LiveEntityLifecycleStressTests
public Vector3 LastServerPosition { get; set; } public Vector3 LastServerPosition { get; set; }
public double LastServerPositionTime { get; set; } public double LastServerPositionTime { get; set; }
public Vector3 LastShadowSyncPosition { get; set; } public Vector3 LastShadowSyncPosition { get; set; }
public Quaternion LastShadowSyncOrientation { get; set; }
public void BindCanonicalCell(Func<uint> read, Action<uint> write) public void BindCanonicalCell(Func<uint> read, Action<uint> write)
{ {
_readCell = read; _readCell = read;

View file

@ -164,6 +164,113 @@ public sealed class LiveEntityPresentationControllerTests
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld); 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<MeshRef>(),
});
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] [Fact]
public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns() public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns()
{ {
@ -200,6 +307,285 @@ public sealed class LiveEntityPresentationControllerTests
Assert.Equal(1, fixture.Shadows.TotalRegistered); 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<WorldEntity>()));
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<MeshRef>(),
});
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<WorldEntity>(
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<MeshRef>(),
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<WorldEntity>()));
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<WorldEntity>(
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<MeshRef>(),
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<MeshRef>(),
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<WorldEntity>()));
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] [Fact]
public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset() public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset()
{ {
@ -260,6 +646,7 @@ public sealed class LiveEntityPresentationControllerTests
public ShadowObjectRegistry Shadows { get; } = new(); public ShadowObjectRegistry Shadows { get; } = new();
public WorldEntity Entity { get; } public WorldEntity Entity { get; }
public LiveEntityPresentationController Controller { get; } public LiveEntityPresentationController Controller { get; }
public Action<uint>? OnTypedPlay { get; set; }
public Fixture( public Fixture(
PhysicsStateFlags initialState, PhysicsStateFlags initialState,
@ -306,6 +693,7 @@ public sealed class LiveEntityPresentationControllerTests
{ {
TypedPlays.Add((owner, type, intensity)); TypedPlays.Add((owner, type, intensity));
PresentationOrder.Add($"effect:{type:X2}"); PresentationOrder.Add($"effect:{type:X2}");
OnTypedPlay?.Invoke(type);
return true; return true;
}, },
(parent, noDraw) => (parent, noDraw) =>

View file

@ -1,5 +1,7 @@
using System.Numerics;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.Core.Net; using AcDream.Core.Net;
@ -189,6 +191,195 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(new[] { false, true }, visibilityEdges); 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] [Fact]
public void SpatialComponentWorksets_FollowLoadedProjectionWhileLogicalOwnersSurvive() public void SpatialComponentWorksets_FollowLoadedProjectionWhileLogicalOwnersSurvive()
{ {
@ -313,6 +504,41 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(2, runtime.AnimationRuntimes.Count); 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<AnimationRuntime>(
() => runtime);
var ids = new HashSet<uint>();
view.CopySpatialIdsTo(ids);
foreach (KeyValuePair<uint, AnimationRuntime> _ in view) { }
int visits = 0;
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
{
view.CopySpatialIdsTo(ids);
foreach (KeyValuePair<uint, AnimationRuntime> _ 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] [Fact]
public void SpatialComponentWorksets_IsolateGuidReuseAcrossVisibilityCallbacks() public void SpatialComponentWorksets_IsolateGuidReuseAcrossVisibilityCallbacks()
{ {
@ -350,6 +576,60 @@ public sealed class LiveEntityRuntimeTests
Assert.NotSame(oldAnimation, indexed.Value); 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] [Fact]
public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown() public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown()
{ {
@ -667,15 +947,24 @@ public sealed class LiveEntityRuntimeTests
id => Entity(id, guid))!; id => Entity(id, guid))!;
var animation = new AnimationRuntime(entity); var animation = new AnimationRuntime(entity);
runtime.SetAnimationRuntime(guid, animation); 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; uint localId = entity.Id;
Assert.True(runtime.TryMarkWorldSpawnPublished(guid)); Assert.True(runtime.TryMarkWorldSpawnPublished(guid));
Assert.True(runtime.TryApplyPickup( Assert.True(runtime.TryApplyPickup(
new PickupEvent.Parsed(guid, spawn.InstanceSequence, 11), new PickupEvent.Parsed(guid, spawn.InstanceSequence, 11),
out _)); out _));
Assert.False(record.ObjectClock.IsActive);
Assert.False(remote.Body.IsActive);
Assert.Equal(initialClockEpoch + 1, record.ObjectClockEpoch);
Assert.True(runtime.WithdrawLiveEntityProjection(guid)); 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.FullCellId);
Assert.Equal(0u, record.CanonicalLandblockId); Assert.Equal(0u, record.CanonicalLandblockId);
Assert.False(runtime.ShouldAdvanceRootRuntime(guid)); Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
@ -711,6 +1000,9 @@ public sealed class LiveEntityRuntimeTests
Assert.Same(entity, same); Assert.Same(entity, same);
Assert.Equal(localId, same.Id); 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.False(runtime.TryMarkWorldSpawnPublished(guid));
Assert.True(runtime.ShouldAdvanceRootRuntime(guid)); Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
Assert.Equal(0x01010002u, record.FullCellId); Assert.Equal(0x01010002u, record.FullCellId);
@ -760,6 +1052,267 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.ShouldAdvanceRootRuntime(guid)); 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] [Fact]
public void PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder() public void PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder()
{ {
@ -1625,6 +2178,94 @@ public sealed class LiveEntityRuntimeTests
Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities)); 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] [Fact]
public void RebucketSpatialCallback_NewerSameRecordRebucketSupersedesOuterMove() public void RebucketSpatialCallback_NewerSameRecordRebucketSupersedesOuterMove()
{ {

View file

@ -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<string>();
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<string>();
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<string>();
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<InvalidOperationException>(() => 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<Counter, int> 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);
}
}

View file

@ -8,6 +8,8 @@ namespace AcDream.Core.Tests.Input;
public class PlayerMovementControllerTests public class PlayerMovementControllerTests
{ {
private static float ObjectTick => PhysicsBody.MinQuantum + 0.001f;
private static PhysicsEngine MakeFlatEngine() private static PhysicsEngine MakeFlatEngine()
{ {
var engine = new PhysicsEngine(); var engine = new PhysicsEngine();
@ -21,6 +23,29 @@ public class PlayerMovementControllerTests
return engine; 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] [Fact]
public void Update_NoInput_PositionUnchanged() public void Update_NoInput_PositionUnchanged()
{ {
@ -156,10 +181,10 @@ public class PlayerMovementControllerTests
var start = new Vector3(96f, 96f, 50f); var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001); controller.SetPosition(start, 0x0001);
controller.Yaw = 0f; controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource(_ => Vector3.Zero); controller.AttachAnimationRootMotionSource((_, _) => { });
MovementResult result = controller.Update( MovementResult result = controller.Update(
PhysicsBody.MinQuantum, ObjectTick,
new MovementInput(Forward: true)); new MovementInput(Forward: true));
Assert.Equal(start, result.Position); Assert.Equal(start, result.Position);
@ -175,10 +200,11 @@ public class PlayerMovementControllerTests
controller.SetPosition(start, 0x0001); controller.SetPosition(start, 0x0001);
controller.Yaw = 0f; controller.Yaw = 0f;
controller.ObjectScale = 2f; 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( MovementResult result = controller.Update(
PhysicsBody.MinQuantum, ObjectTick,
new MovementInput(Forward: true)); new MovementInput(Forward: true));
// Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles // Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles
@ -188,24 +214,313 @@ public class PlayerMovementControllerTests
} }
[Fact] [Fact]
public void Update_SubQuantumAnimationRootDeltas_AccumulateIntoOnePhysicsStep() public void Update_SubQuantumFrames_AdvanceAnimationOnceAtObjectThreshold()
{ {
var controller = new PlayerMovementController(MakeFlatEngine()); var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f); var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001); controller.SetPosition(start, 0x0001);
controller.Yaw = 0f; 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( MovementResult first = controller.Update(
PhysicsBody.MinQuantum * 0.5f, ObjectTick * 0.5f,
new MovementInput(Forward: true)); new MovementInput(Forward: true));
MovementResult second = controller.Update( MovementResult second = controller.Update(
PhysicsBody.MinQuantum * 0.5f, ObjectTick * 0.5f,
new MovementInput(Forward: true)); new MovementInput(Forward: true));
Assert.Equal(start, first.Position); Assert.Equal(start, first.Position);
Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3); Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3);
Assert.Equal(start.Y, second.Position.Y, 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] [Fact]
@ -217,7 +532,7 @@ public class PlayerMovementControllerTests
controller.SetPosition(start, 0x0001); controller.SetPosition(start, 0x0001);
controller.Yaw = 0f; 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.True(firstTick.Position.X > start.X, "Physics tick should advance the authoritative body position");
Assert.Equal(start.X, firstTick.RenderPosition.X, precision: 4); 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.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; 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)); controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
var snapped = new Vector3(120f, 80f, 50f); var snapped = new Vector3(120f, 80f, 50f);
@ -257,7 +572,7 @@ public class PlayerMovementControllerTests
var controller = new PlayerMovementController(MakeFlatEngine()); var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; controller.Yaw = 0f;
controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocity = controller.BodyVelocity; Vector3 velocity = controller.BodyVelocity;
Assert.True(velocity.LengthSquared() > 0f); Assert.True(velocity.LengthSquared() > 0f);
@ -305,12 +620,25 @@ public class PlayerMovementControllerTests
var controller = new PlayerMovementController(engine); var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; 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)); var stale = controller.Update(PhysicsBody.HugeQuantum + 0.1f, new MovementInput(Forward: true));
Assert.Equal(moved.Position.X, stale.Position.X, precision: 4); Assert.Equal(moved.Position.X, stale.Position.X, precision: 4);
Assert.Equal(stale.Position, stale.RenderPosition); Assert.Equal(stale.Position, stale.RenderPosition);
Assert.Equal(advancesBeforeDiscard, animationAdvances);
Assert.Equal(hooksBeforeDiscard, hookPasses);
} }
[Fact] [Fact]

View file

@ -283,6 +283,27 @@ public sealed class AnimationSequencerTests
$"Expected orientation near {rot.Z} but got {transforms[0].Orientation.Z}"); $"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<Animation>)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] [Fact]
public void Advance_FrameWrapsAtHighFrame() public void Advance_FrameWrapsAtHighFrame()
{ {
@ -1883,6 +1904,36 @@ public sealed class AnimationSequencerTests
// ── Helpers ────────────────────────────────────────────────────────────── // ── 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);
}
/// <summary> /// <summary>
/// Expose the core CSequence's FrameNumber via reflection (test-only). /// Expose the core CSequence's FrameNumber via reflection (test-only).
/// R1-P5 rehost (2026-07-02): _framePosition lived directly on /// R1-P5 rehost (2026-07-02): _framePosition lived directly on

View file

@ -1,6 +1,7 @@
using AcDream.Content.Vfx; using AcDream.Content.Vfx;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Tests.Conformance; using AcDream.Core.Tests.Conformance;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -23,6 +24,7 @@ public sealed class HumanoidMotionTableRootMotionTests
private const uint RunForward = 0x40000007u; private const uint RunForward = 0x40000007u;
private const uint Ready = 0x41000003u; private const uint Ready = 0x41000003u;
private const uint InterpretedWalkForward = 0x45000005u; private const uint InterpretedWalkForward = 0x45000005u;
private const uint TurnRight = 0x6500000Du;
[Theory] [Theory]
[InlineData(WalkForward)] [InlineData(WalkForward)]
@ -76,4 +78,32 @@ public sealed class HumanoidMotionTableRootMotionTests
distance > 1f, distance > 1f,
$"Installed retail WalkForward should author root travel; got {distance:F3} m over 2 s."); $"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<MotionTable>(dats.Get<MotionTable>(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);
}
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit; using Xunit;
namespace AcDream.Core.Tests.Physics; namespace AcDream.Core.Tests.Physics;
@ -27,6 +28,164 @@ public sealed class InterpolationManagerTests
private static InterpolationManager Make() => new InterpolationManager(); 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 // Queue mechanics
// ========================================================================= // =========================================================================

View file

@ -0,0 +1,163 @@
using System.Numerics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// Conformance coverage for retail <c>Frame::combine</c> (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.
/// </summary>
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<Quaternion> InvalidRotations => new()
{
Quaternion.Zero,
new Quaternion(float.NaN, 0f, 0f, 1f),
new Quaternion(float.PositiveInfinity, 0f, 0f, 1f),
};
}

View file

@ -13,8 +13,7 @@ namespace AcDream.Core.Tests.Physics.Motion;
/// R2-Q5 — <see cref="MotionTableDispatchSink"/>: the funnel's dispatches go /// R2-Q5 — <see cref="MotionTableDispatchSink"/>: the funnel's dispatches go
/// straight into <see cref="AnimationSequencer.PerformMovement"/> (no axis /// straight into <see cref="AnimationSequencer.PerformMovement"/> (no axis
/// collection, no priority pick, no fallback chain — GetObjectSequence /// collection, no priority pick, no fallback chain — GetObjectSequence
/// 0x00522860 + is_allowed decide) with the TurnApplied/TurnStopped /// 0x00522860 + is_allowed decide).
/// ObservedOmega seam.
/// </summary> /// </summary>
public class MotionTableDispatchSinkTests public class MotionTableDispatchSinkTests
{ {
@ -100,15 +99,10 @@ public class MotionTableDispatchSinkTests
} }
[Fact] [Fact]
public void ApplyMotion_Turn_Branch4Modifier_FiresTurnApplied_NoCycleChange() public void ApplyMotion_Turn_Branch4Modifier_PreservesCycleAndAppliesDatOmega()
{ {
var seq = MakeSequencer(); var seq = MakeSequencer();
uint? turnMotion = null; var sink = new MotionTableDispatchSink(seq);
float turnSpeed = 0f;
var sink = new MotionTableDispatchSink(seq)
{
TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; },
};
sink.ApplyMotion(Walk, 1.0f); sink.ApplyMotion(Walk, 1.0f);
sink.ApplyMotion(TurnRight, 1.5f); sink.ApplyMotion(TurnRight, 1.5f);
@ -116,22 +110,19 @@ public class MotionTableDispatchSinkTests
// The AP-73 mechanism for real: substate cycle untouched, the turn // The AP-73 mechanism for real: substate cycle untouched, the turn
// is a MotionState modifier with its dat omega combined. // is a MotionState modifier with its dat omega combined.
Assert.Equal(Walk, seq.CurrentMotion); Assert.Equal(Walk, seq.CurrentMotion);
Assert.Equal((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed));
Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3); Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3);
} }
[Fact] [Fact]
public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics() public void StopMotion_Turn_UnwindsModifierPhysics()
{ {
var seq = MakeSequencer(); var seq = MakeSequencer();
bool stopped = false; var sink = new MotionTableDispatchSink(seq);
var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true };
sink.ApplyMotion(Walk, 1.0f); sink.ApplyMotion(Walk, 1.0f);
sink.ApplyMotion(TurnRight, 1.5f); sink.ApplyMotion(TurnRight, 1.5f);
sink.StopMotion(TurnRight); sink.StopMotion(TurnRight);
Assert.True(stopped);
// StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat // StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat
// omega + modifier unlinked. // omega + modifier unlinked.
Assert.Equal(0f, seq.CurrentOmega.Z, 3); Assert.Equal(0f, seq.CurrentOmega.Z, 3);

View file

@ -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. /// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270.
/// ///
/// <para> /// <para>
/// <b>The packer-reuse trap (V0-pins §P5 correction):</b> acdream's /// <b>The scalar-convention trap (V0-pins §P5 correction):</b>
/// outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is wire-correct at /// <see cref="MoveToMath.GetHeading"/> must use the correct scalar bridge
/// the QUATERNION level but its internal scalar intermediate /// from acdream yaw (yaw=0 faces +X, per
/// (<c>headingDeg = 180 - yawDeg</c>) is holtburger's shifted convention,
/// NOT retail's wire convention. <see cref="MoveToMath.GetHeading"/> must
/// use the CORRECT scalar bridge from acdream yaw (yaw=0 faces +X, per
/// <c>PlayerMovementController.cs:1022-1025</c>): <c>heading = (90 - /// <c>PlayerMovementController.cs:1022-1025</c>): <c>heading = (90 -
/// yawDeg) mod 360</c> — NOT <c>180 - yawDeg</c>. /// yawDeg) mod 360</c> — NOT <c>180 - yawDeg</c>.
/// </para> /// </para>

View file

@ -164,6 +164,27 @@ public sealed class MovementManagerTests
Assert.Equal(0, calls[0]); 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] [Fact]
public void PerformMovement_MoveToType_WithoutFactory_Fails0x47() public void PerformMovement_MoveToType_WithoutFactory_Fails0x47()
{ {

View file

@ -21,9 +21,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
// the scalar) and drains pending_motions synthetically, so the two legs where // the scalar) and drains pending_motions synthetically, so the two legs where
// the live #170 residual actually lives have ZERO coverage: // the live #170 residual actually lives have ZERO coverage:
// //
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink. // 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink
// TurnApplied → ObservedOmega → per-tick quaternion integration // DAT MotionData.Omega → CSequence's complete root Frame
// MoveToMath.GetHeading → HandleTurnToHeading's HeadingGreater test; // Frame::combine → HandleTurnToHeading's HeadingGreater test;
// 2. the REAL drain: MotionTableManager pending_animations countdown fed by // 2. the REAL drain: MotionTableManager pending_animations countdown fed by
// CSequence AnimDone hooks (link-anim completions), popping // CSequence AnimDone hooks (link-anim completions), popping
// CMotionInterp.pending_motions via the MotionDoneTarget seam. // CMotionInterp.pending_motions via the MotionDoneTarget seam.
@ -31,11 +31,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
// This harness wires a real MotionInterpreter + AnimationSequencer + // This harness wires a real MotionInterpreter + AnimationSequencer +
// MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's // MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's
// EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in // EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in
// GameWindow.TickAnimations' per-entity order for the grounded branch=MOVETO // the retail per-object order: CSequence update → PositionManager adjust →
// path (GameWindow.cs:9697 HandleTargetting → 9994 TickRemoteMoveTo → // complete Frame combine → hook processing → Target/Movement/PartArray/
// 10024 get_state_velocity refresh → 10050 manual omega integration → // Position manager tail. Wire events (aggro stance UM, mt-6 arms, attack
// 10247 Sequencer.Advance → 10306 AnimationDone per AnimDoneHook →
// 10309 Manager.UseTime). Wire events (aggro stance UM, mt-6 arms, attack
// UMs) replay the exact live sequence captured in launch-drainq.log // UMs) replay the exact live sequence captured in launch-drainq.log
// (2026-07-04, guid 0x80000BE5, Mite Scamp chasing the fleeing player). // (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 CreatureHost _creatureHost;
private readonly TargetHost _playerHost; private readonly TargetHost _playerHost;
/// <summary>GameWindow's RemoteMotion.ObservedOmega twin.</summary>
public Vector3 ObservedOmega;
/// <summary>Scripted player (chase target) world position.</summary> /// <summary>Scripted player (chase target) world position.</summary>
public Vector3 PlayerPos; public Vector3 PlayerPos;
public Vector3 PlayerVelocity; public Vector3 PlayerVelocity;
@ -162,17 +157,7 @@ internal sealed class RemoteChaseHarness
}; };
// ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ── // ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ──
Sink = new MotionTableDispatchSink(Seq) 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,
};
Interp.DefaultSink = Sink; Interp.DefaultSink = Sink;
// #174: production binds the seam to Manager.HandleEnterWorld // #174: production binds the seam to Manager.HandleEnterWorld
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) — // (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
@ -425,64 +410,52 @@ internal sealed class RemoteChaseHarness
// Player (chase target) moves per its scripted velocity. // Player (chase target) moves per its scripted velocity.
PlayerPos += PlayerVelocity * Dt; PlayerPos += PlayerVelocity * Dt;
// 1. Voyeur delivery (player host HandleTargetting → this entity's // CPartArray::Update emits one complete local Frame: authored PosFrames
// HandleUpdateTarget; GameWindow.cs:8094 runs before the per-remote // plus MotionData velocity/omega (CSequence::apply_physics 0x00524AB0).
// loop, 9697 in-loop). var rootFrame = new Frame
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)
{ {
float omegaMag = ObservedOmega.Length(); Origin = Vector3.Zero,
var axis = ObservedOmega / omegaMag; Orientation = Quaternion.Identity,
float angle = omegaMag * Dt; };
var deltaRot = Quaternion.CreateFromAxisAngle(axis, angle); Seq.Advance(Dt, rootFrame);
Body.Orientation = Quaternion.Normalize(
Quaternion.Multiply(Body.Orientation, deltaRot));
}
// 5. R5-V3 (#171): the sticky steer — GameWindow's legacy-branch slot // PositionManager receives that same Frame and may replace its motion
// (retail UpdatePositionInternal PositionManager::adjust_offset // (sticky/interpolation) before Frame::combine applies translation with
// @0x00512d0e, composed BEFORE the velocity integration). Origin is // the OLD orientation, then post-multiplies the relative orientation.
// mover-local; the rotation carries a RELATIVE heading (identity = var pmDelta = new MotionDeltaFrame
// untouched = no turn). {
var pmDelta = new MotionDeltaFrame(); Origin = rootFrame.Origin,
Orientation = rootFrame.Orientation,
};
Pm.AdjustOffset(pmDelta, Dt); Pm.AdjustOffset(pmDelta, Dt);
if (pmDelta.Origin != Vector3.Zero) if (pmDelta.Origin != Vector3.Zero)
Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation); Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation);
if (!pmDelta.Orientation.IsIdentity) if (!pmDelta.Orientation.IsIdentity)
Body.Orientation = MoveToMath.SetHeading( Body.Orientation = Quaternion.Normalize(
Body.Orientation, Body.Orientation * pmDelta.Orientation);
MoveToMath.GetHeading(Body.Orientation) + pmDelta.GetHeading());
// 5b. Position integration (UpdatePhysicsInternal, simplified: grounded, // The fixture has no gravity or independent physical velocity, but run
// no gravity participation for this scenario). // the same physics primitive so a future fixture cannot accidentally
Body.Position += Body.Velocity * Dt; // bypass the retail slot.
Body.calc_acceleration();
Body.UpdatePhysicsInternal(Dt);
// 5c. R5-V3: PositionManager::UseTime (retail UpdateObjectInternal // process_hooks precedes the manager tail. AnimationDone decrements the
// tail @0x005159b3) — the sticky 1 s lease watchdog. // exact pending-animation node that owns the transition.
Pm.UseTime();
// 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone
// hooks into the manager countdown, zero-tick sweep.
Seq.Advance(Dt);
var hooks = Seq.ConsumePendingHooks(); var hooks = Seq.ConsumePendingHooks();
for (int i = 0; i < hooks.Count; i++) for (int i = 0; i < hooks.Count; i++)
{ {
if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook) if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook)
Seq.Manager.AnimationDone(success: true); 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(); Seq.Manager.UseTime();
Pm.UseTime();
// ── Observables ── // ── Observables ──
uint substate = Seq.Manager.State.Substate; uint substate = Seq.Manager.State.Substate;
@ -509,7 +482,7 @@ internal sealed class RemoteChaseHarness
public void Snapshot(string tag) public void Snapshot(string tag)
{ {
string line = FormattableString.Invariant( 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); Trace.Add(line);
_log?.WriteLine(line); _log?.WriteLine(line);
} }
@ -627,7 +600,11 @@ internal sealed class RemoteChaseHarness
AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink)); AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink));
// Global (unstyled) TurnRight modifier — physics-only in Branch 4. // 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); 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 /// 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 /// 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 /// 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. /// installs the forward cycle. Total budget: 4 s of ticks is generous.
/// </summary> /// </summary>
[Theory] [Theory]
@ -692,7 +669,7 @@ public sealed class RemoteChaseEndToEndHarnessTests
Assert.True(installTick >= 0, Assert.True(installTick >= 0,
$"forward cycle never installed within 6 s of the arm (bearing {bearingDeg}°); " + $"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} " + $"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), Assert.True(installTick <= Seconds(4f),
$"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " + $"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " +
"retail installs within the turn duration (~1-2 s)"); "retail installs within the turn duration (~1-2 s)");

View file

@ -139,7 +139,7 @@ public sealed class MotionVelocityPipelineTests
Assert.True(v.X < 0f, "strafe-left velocity must be negative"); 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] [Fact]
public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor() public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor()

View file

@ -104,6 +104,40 @@ public sealed class ProjectilePhysicsStepperTests
Assert.Equal(50f, body.CachedVelocity.Y, 3); 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] [Fact]
public void Advance_GravityUsesFinalPhysicsState_NotParsedAcceleration() public void Advance_GravityUsesFinalPhysicsState_NotParsedAcceleration()
{ {

View file

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

View file

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