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:
parent
31a0889f08
commit
f961d70023
77 changed files with 12513 additions and 1871 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 `|Body−LastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip — incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site — a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn — the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) — only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 → `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) |
|
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection + authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
|
||||||
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
| AP-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`) |
|
||||||
|
|
|
||||||
408
docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md
Normal file
408
docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md
Normal 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.
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
226
src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs
Normal file
226
src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs
Normal 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;
|
||||||
|
}
|
||||||
56
src/AcDream.App/Physics/LiveEntityShadowPublisher.cs
Normal file
56
src/AcDream.App/Physics/LiveEntityShadowPublisher.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
145
src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs
Normal file
145
src/AcDream.App/Physics/RemoteInboundMotionDispatcher.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
509
src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
Normal file
509
src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs
Normal 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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
Normal file
90
src/AcDream.App/Rendering/StaticLiveRootCommitter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
src/AcDream.App/World/RetailInboundEventDispatcher.cs
Normal file
123
src/AcDream.App/World/RetailInboundEventDispatcher.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 > 3 → snap to tail, clear queue)
|
/// <c>UseTime</c> blip-check (fail_count > 3 → snap to tail, clear queue)
|
||||||
|
|
@ -259,14 +312,74 @@ public sealed class InterpolationManager
|
||||||
/// Max motion-table speed for this entity's current cycle (m/s).
|
/// 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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
101
src/AcDream.Core/Physics/RetailObjectActivityGate.cs
Normal file
101
src/AcDream.Core/Physics/RetailObjectActivityGate.cs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
122
src/AcDream.Core/Physics/RetailObjectQuantumClock.cs
Normal file
122
src/AcDream.Core/Physics/RetailObjectQuantumClock.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
163
tests/AcDream.Core.Tests/Physics/Motion/MotionDeltaFrameTests.cs
Normal file
163
tests/AcDream.Core.Tests/Physics/Motion/MotionDeltaFrameTests.cs
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)");
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue