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,
|
||||
component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component
|
||||
book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect
|
||||
foundation. **Next:** connected single-client magic UI/casting visual gate, then the final two-client
|
||||
portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6/TS-42, Track MP0.
|
||||
foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete;
|
||||
three independent conformance/architecture/adversarial re-reviews are clean, the Release build has
|
||||
zero warnings, and the full suite is 6,446 passed / 5 skipped. Its remaining ownership cleanup and
|
||||
registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6
|
||||
locomotion/collision/projectile/teleport visual and performance gate, then the final two-client
|
||||
portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6 residuals, Track MP0.
|
||||
Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below.
|
||||
|
||||
For canonical state, read in this order:
|
||||
|
|
|
|||
|
|
@ -109,8 +109,12 @@ movement queries.
|
|||
**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent,
|
||||
component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component
|
||||
book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect
|
||||
foundation. **Next:** connected single-client magic UI/casting visual gate, then the final two-client
|
||||
portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6/TS-42, Track MP0.
|
||||
foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete;
|
||||
three independent conformance/architecture/adversarial re-reviews are clean, the Release build has
|
||||
zero warnings, and the full suite is 6,446 passed / 5 skipped. Its remaining ownership cleanup and
|
||||
registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6
|
||||
locomotion/collision/projectile/teleport visual and performance gate, then the final two-client
|
||||
portal-out/materialization observer gate. **Carried:** #145-residual, #116, R6 residuals, Track MP0.
|
||||
Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below.
|
||||
|
||||
For canonical state, read in this order:
|
||||
|
|
|
|||
|
|
@ -207,16 +207,22 @@ src/
|
|||
Physics/
|
||||
ProjectileController.cs -> live-record projectile orchestration/corrections
|
||||
RemotePhysicsUpdater.cs -> ordinary/Hidden remote narrow-tick integration
|
||||
LiveEntityOrdinaryPhysicsUpdater.cs -> manager-less body Transition commits
|
||||
RemoteInboundMotionDispatcher.cs -> animation-optional retail UM funnel
|
||||
RemoteTeleportController.cs -> incarnation-scoped loaded/pending placement owner
|
||||
RemoteTeleportHook.cs -> ordered retail teleport teardown seam
|
||||
RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit
|
||||
World/
|
||||
LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership
|
||||
RetailInboundEventDispatcher.cs -> update-thread packet/frame FIFO barrier
|
||||
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
|
||||
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
|
||||
RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier
|
||||
Rendering/
|
||||
GameWindow.cs -> still owns too much runtime wiring
|
||||
LiveEntityAnimationScheduler.cs -> canonical ordinary live-object workset
|
||||
RetailStaticAnimatingObjectScheduler.cs -> retail static-animation workset
|
||||
StaticLiveRootCommitter.cs -> static root pose/collision commit boundary
|
||||
TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path
|
||||
Wb/WbDrawDispatcher.cs -> ordinary live/static entity draw dispatch
|
||||
Wb/EnvCellRenderer.cs -> indoor cell-shell draw path
|
||||
|
|
@ -285,6 +291,67 @@ What exists and is active:
|
|||
owns the frame;
|
||||
delete, generation replacement, pickup/parent leave-world, and session reset
|
||||
use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map.
|
||||
- `LiveEntityAnimationScheduler` is the one update-thread walk over spatial
|
||||
live roots. It admits each incarnation's `RetailObjectQuantumClock`, advances
|
||||
PartArray state, then selects exactly one movement owner: remote motion, the
|
||||
retained projectile body, or `LiveEntityOrdinaryPhysicsUpdater` for a
|
||||
manager-less canonical body. Every hook boundary revalidates record,
|
||||
component, and object-clock epoch before transition, manager-tail, pose, or
|
||||
rebucket commits. `RetailStaticAnimatingObjectScheduler` remains a separate
|
||||
workset, matching `CPhysics::UseTime`: Setup DefaultAnimation is installed at
|
||||
PartArray construction, while DAT and live PhysicsState-Static owners advance
|
||||
whole elapsed intervals through `animate_static_object` semantics. A live
|
||||
static owner shares the record's canonical PartArray and PhysicsBody; raw
|
||||
static omega commits the root, effect pose, and collision shadow together,
|
||||
while zero omega does not reflood collision and Hidden never restores it.
|
||||
The typed animation view copies the runtime's concrete spatial dictionary
|
||||
through reusable storage, so update and render classification allocate
|
||||
nothing and scale with resident owners rather than retained KnownObjects.
|
||||
- `RemoteInboundMotionDispatcher` is the single remote
|
||||
`MovementManager::unpack_movement` owner. Animated and animation-less live
|
||||
objects differ only by the presence of a PartArray dispatch sink; packet
|
||||
head interrupt/style routing, MoveTo cases, case-0 wholesale state, sticky,
|
||||
and long-jump ordering cannot drift between two `GameWindow` branches.
|
||||
- Ordinary remote/manager-less Transition commits publish the resolved body,
|
||||
`WorldEntity` root, and full cell before invoking the canonical rebucket
|
||||
writer. That writer is a re-entrant lifetime boundary: the caller immediately
|
||||
revalidates the exact record/runtime and publishes no collision shadow or
|
||||
manager tail if the destination became pending or the GUID was replaced.
|
||||
`LiveEntityPresentationController` owns the symmetric non-projectile
|
||||
collision-residency edge: loaded-to-pending suspends the retained shadow,
|
||||
hydration restores it immediately, and `OnLiveEntityReady` reconciles an
|
||||
object that materialized pending before its collision registration existed.
|
||||
Direct local-player and authoritative remote publication use the same
|
||||
ordering: commit root, rebucket, then prove exact-record spatial residency
|
||||
before touching collision. Remote shadows are pose/cell-gated, including
|
||||
complete sign-invariant orientation for offset/multipart Setup shapes and a
|
||||
forced refresh on cell-only transitions. Projectile residency remains
|
||||
exclusively in `ProjectileController`.
|
||||
- `RetailInboundEventDispatcher` wraps every live-object session callback and
|
||||
each complete live-object frame phase. Synchronous App observers can enqueue
|
||||
another packet, but that packet cannot interleave halfway through the older
|
||||
packet's collision, rebucket, shadow, pose, hook, or manager tail. The live
|
||||
record retains independent Position, State, Vector, and Movement authority
|
||||
versions plus a narrow shared velocity version for the three packet families
|
||||
that can install velocity. This mirrors retail's single update-thread FIFO
|
||||
without incorrectly making an accepted State cancel an accepted Position.
|
||||
Its direct frame/packet path accepts state plus a cached static callback and
|
||||
performs zero allocation; only real nested reentrancy materializes a queued
|
||||
heterogeneous operation.
|
||||
- Projectile prediction is a two-phase transaction around retail's
|
||||
`process_hooks` slot. `BeginQuantum` holds a candidate while leaving the
|
||||
canonical body at its begin frame; `CompleteQuantum` commits only if the
|
||||
exact record/runtime and prediction version still match. Position, Vector,
|
||||
and State corrections therefore win if accepted between the two halves.
|
||||
Teleport placement uses the same exact-record and per-channel authority
|
||||
checks, so a newer Position supersedes an older resolver while an independent
|
||||
State is preserved alongside the completed placement.
|
||||
- `EntityEffectPoseRegistry` assigns a monotonic lifetime to every local-ID
|
||||
incarnation. `AnimationHookFrameQueue` snapshots it before semantic
|
||||
`AnimationDone` callbacks and revalidates it before every semantic completion
|
||||
and every routed hook. A delete/local-ID reuse during capture or during an
|
||||
earlier hook can never advance the displaced sequencer or send the old
|
||||
owner's remaining sound, particle, or light hooks to its replacement.
|
||||
- `RemoteTeleportController` owns the placement half of a fresh remote
|
||||
teleport after `RemoteTeleportHook` has torn down movement/target state. It
|
||||
collision-seats loaded destinations through `RemoteTeleportPlacement`; an
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ src/AcDream.App/
|
|||
├── Rendering/
|
||||
│ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator
|
||||
│ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI)
|
||||
│ ├── LiveEntityAnimationScheduler.cs # shipped: ordinary live-object update workset
|
||||
│ ├── RetailStaticAnimatingObjectScheduler.cs # shipped: separate static-animation workset
|
||||
│ ├── StaticLiveRootCommitter.cs # live static root → pose + collision boundary
|
||||
│ ├── TerrainModernRenderer.cs # (already exists)
|
||||
│ ├── TextureCache.cs # (already exists)
|
||||
│ ├── ParticleRenderer.cs # (already exists)
|
||||
|
|
@ -176,12 +179,16 @@ src/AcDream.App/
|
|||
├── Physics/
|
||||
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
|
||||
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration
|
||||
│ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path
|
||||
│ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate
|
||||
│ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel
|
||||
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
|
||||
│ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions
|
||||
│ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit
|
||||
├── World/
|
||||
│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
|
||||
│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation
|
||||
│ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier
|
||||
│ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects
|
||||
│ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain
|
||||
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations
|
||||
|
|
@ -239,6 +246,64 @@ allocators fail fast instead of wrapping into another landblock's namespace.
|
|||
All other non-Parent packet
|
||||
families still need the future general queue tracked by divergence AD-32.
|
||||
|
||||
The per-frame object body is no longer an animation-dictionary loop inside
|
||||
`GameWindow`. `LiveEntityAnimationScheduler` snapshots canonical spatial root
|
||||
records and advances the incarnation-stable object clock, PartArray, hooks,
|
||||
one selected movement owner, and manager tail in retail order. Manager-less
|
||||
bodies delegate their candidate/Transition/cell/shadow commit to
|
||||
`LiveEntityOrdinaryPhysicsUpdater`; retained projectile bodies and remote
|
||||
MovementManagers remain mutually exclusive movement owners. Static animation
|
||||
is deliberately separate: `RetailStaticAnimatingObjectScheduler` owns the
|
||||
`CPhysics::static_animating_objects` workset for DAT and live PhysicsState-
|
||||
Static owners with Setup DefaultAnimation. Both schedulers are wired by
|
||||
`GameWindow`, but neither owns GUID identity or logical resources.
|
||||
The typed animation view fills its reusable snapshot and render-ID set from
|
||||
`LiveEntityRuntime`'s concrete spatial dictionary, avoiding interface-enumerator
|
||||
boxing on both update and render hot paths.
|
||||
`RemoteInboundMotionDispatcher` similarly keeps UpdateMotion protocol behavior
|
||||
outside `GameWindow`: GameWindow resolves the canonical record/body and the
|
||||
optional PartArray sink, while one dispatcher owns retail's interrupt, style,
|
||||
MoveTo/type-0, sticky, and standing-long-jump order. Static root projection is
|
||||
bounded by `StaticLiveRootCommitter`, which synchronizes changed roots to
|
||||
effects and collision without rebuilding zero-omega shadows or resurrecting a
|
||||
Hidden/withdrawn registration.
|
||||
|
||||
Synchronous network and lifecycle callbacks are bounded by
|
||||
`RetailInboundEventDispatcher`. It owns no wire state and no identity; it only
|
||||
serializes nested live-object operations until the current packet or full
|
||||
object-frame tail completes. State-bearing direct dispatch is allocation-free;
|
||||
only a genuinely nested operation allocates its retained queue wrapper.
|
||||
`LiveEntityRecord` then supplies exact-incarnation
|
||||
and per-channel authority versions at callback boundaries. Position, State,
|
||||
Vector, and Movement remain independent, while a separate velocity version
|
||||
invalidates only an older operation that would overwrite a newer velocity
|
||||
installed by Position, Vector, or Movement. This prevents re-entrant App
|
||||
observers from creating call-stack ordering that retail's update-thread packet
|
||||
FIFO cannot produce.
|
||||
|
||||
Pose-dependent hook deferral is similarly incarnation-scoped rather than GUID-
|
||||
or local-ID-scoped. `EntityEffectPoseRegistry` publishes a monotonic pose-owner
|
||||
lifetime, and `AnimationHookFrameQueue` captures it before semantic callbacks
|
||||
and rechecks it before each semantic AnimationDone and each routed hook. Static animation retains `process_hooks`
|
||||
until its root, live parts, and children are published; withdrawal invalidates
|
||||
both its prepared pose and pending hook tail.
|
||||
|
||||
Resolved ordinary motion commits its body/root/contact state before the
|
||||
canonical full-cell setter enters `LiveEntityRuntime.RebucketLiveEntity`.
|
||||
Because that setter may synchronously move the projection to pending or replace
|
||||
the GUID, both `RemotePhysicsUpdater` and `LiveEntityOrdinaryPhysicsUpdater`
|
||||
revalidate the exact incarnation before collision or manager-tail publication.
|
||||
Collision residency itself is projection-owned, not updater-owned:
|
||||
`LiveEntityPresentationController` suspends retained non-projectile shadows on
|
||||
every unavailable-projection edge, restores them on hydration, and reconciles
|
||||
the pending-first case at `OnLiveEntityReady` after collision registration.
|
||||
Local projection and both authoritative remote UpdatePosition tails commit the
|
||||
complete root before rebucketing, then publish collision only through an
|
||||
exact-record/spatial-residency gate. Remote reflood tracks translation,
|
||||
sign-invariant complete orientation, and cell changes so an in-place turn or a
|
||||
same-pose EnvCell crossing cannot leave offset Setup shapes stale.
|
||||
The projectile controller retains its separate body/InWorld/shadow edge owner.
|
||||
|
||||
Remote teleport placement is bounded in `Physics/RemoteTeleportController`,
|
||||
not `GameWindow`: it retains at most one pending request per materialized
|
||||
incarnation, scopes it by the live generation and accepted PositionSequence,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
|
||||
## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -105,7 +105,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 89 active rows
|
||||
## 3. Documented approximation (AP) — 85 active rows
|
||||
|
||||
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
|
||||
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
|
||||
|
|
@ -176,9 +176,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-73 | **Character raises mutate optimistically, contrary to retail's server-authoritative flow** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits XP/credits. Named retail permits one request in flight, ghosts the clicked button, and waits for an authoritative quality-change element message before changing displayed state (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE usually accepts client-affordable raises, so its later property echoes conceal the incorrect prediction; Wave 8 removes local mutation and owns one awaiting request | A rejected/reordered raise can display invented state until a later full refresh, and repeated clicks can create multiple speculative spends | `gmAttributeUI`/`gmSkillUI` raise and quality-change paths, pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
|
||||
---
|
||||
|
||||
| AP-75 | **NARROWED 2026-07-17 — adapter-boundary `adjust_motion` + turn-omega synthesis only.** The locomotion-velocity half is RETIRED: CSequence now retains literal `MotionData.Velocity × speed`, and remote translation consumes the complete Frame emitted by `CSequence::update` before interpolation. Remaining: `SetCycle` (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp`; GameWindow's local-player path can still pass raw ids), and (b) synthesizes ±π/2×speed omega for dat-silent TurnRight/TurnLeft after dispatch | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + turn-omega tail); retired velocity consumer in `RemotePhysicsUpdater`/`RemoteMotionCombiner` | (a) preserves unadjusted local callers until they all enter through MotionInterpreter; (b) preserves remote rotation until the literal CSequence orientation delta replaces the ObservedOmega side channel (AP-76) | A DAT with authored turn omega different from π/2 is overridden only when the table is silent; boundary remapping can become a double-adjust if a future caller already normalizes a raw left/back command but still passes its original id | `CMotionInterp::adjust_motion` @305343; `add_motion` 0x005224B0; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire with the remaining R6 rotation/caller unification |
|
||||
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
|
||||
| AP-77 | **NARROWED 2026-07-17 — null-sink/headless `apply_current_movement` fallback only.** When `MotionInterpreter.DefaultSink` is absent, `ApplyCurrentMovementInterpreted` writes grounded body velocity directly via `get_state_velocity`/`set_local_velocity` instead of dispatching through the animation-table backend. Every production animated player and remote binds `MotionTableDispatchSink`; the local controller also consumes literal sequencer root displacement, so this tail is not the live humanoid locomotion owner | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Keeps MotionInterpreter usable in isolated physics tests and for an entity with no animation runtime; production animation owners take the retail sink path | A future production entity instantiated without a DefaultSink silently falls back to command-derived body velocity and can foot-slide or drift from its animation | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when animation-less production objects have an explicit motion owner |
|
||||
| AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification |
|
||||
| AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.App/Input/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner |
|
||||
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
||||
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
||||
|
|
@ -186,9 +185,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
||||
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
||||
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` |
|
||||
| AP-86 | **Remote SHADOW-follows-resolved via a movement-gated per-tick re-flood** (remote-creature de-overlap #184, 2026-07-07): a moving NPC remote's collision shadow is re-registered at its RESOLVED body position (`SyncRemoteShadowToBody` → `ShadowObjects.UpdatePosition`) so neighbours + the player de-overlap / collide against where the monster actually is (== where it renders), and the de-overlap PERSISTS. This is retail-faithful in EFFECT (retail re-registers a moved object's shadow every accepted transition step, `SetPositionInternal`→`remove/add_shadows_to_cells` 0x00515330), but the IMPLEMENTATION differs: acdream runs the FULL `RegisterMultiPart` cell-flood, gated on `|Body−LastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip — incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site — a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn — the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) — only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 → `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) |
|
||||
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection + authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
|
||||
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
||||
| AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` used to rotate CSequence's literal root-motion delta when the interpolation queue is empty/head-reached is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) |
|
||||
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) |
|
||||
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
|
||||
| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
|
||||
|
|
@ -216,7 +214,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
||||
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 32 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed 2026-07-07 — NPC UP unified onto the interp queue, gate retained for orientation)
|
||||
## 4. Temporary stopgap (TS) — 34 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -249,12 +247,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
|
||||
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
|
||||
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
|
||||
| TS-44 | NPC UpdatePosition reconciliation (position + orientation) is SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`). **#184 (2026-07-07) UNIFIED the NPC path onto the interp queue** — the retire-condition mechanism is now ported: the grounded UP routes through `CPhysicsObj::MoveOrTeleport` (Enqueue) and the per-tick catch-up SEEDS the `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES it while armed (0x00555190 / 0x00555430 assigns m_fOrigin), exactly like the player-remote branch — so POSITION is now handled retail-faithfully by the compose-overwrite whether the gate is on or off. The `snapSuppressedByStick` gate is RETAINED for two residuals: (a) it suppresses the during-stick **Enqueue** so a stale server waypoint never enters the queue, and (b) it gates the **orientation** hard-snap (still outside the interp chain — retail's sticky owns facing). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's position mechanism (sticky-overwrites-interp) is now REACHABLE for NPCs (#184 gave them the interp-queue architecture); the gate remains only for orientation + during-stick Enqueue cleanliness | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target) keeps a stale orientation until unstick+next UP; bounded by the 1 s lease | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330 (NPC UP routing, #184); retire the orientation residual in R6 |
|
||||
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
|
||||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
||||
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
||||
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
|
||||
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
|
||||
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
|
||||
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
|
||||
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -872,9 +872,52 @@ diagnostic scaffolding, not yet the final collision system.
|
|||
183-case/funnel/moveto/chase/sticky suites unmodified. Arc close-out:
|
||||
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`. Open follow-ons
|
||||
carried: #167 (ConstraintManager arming). TS-42's per-tick completion/
|
||||
manager order retired in the first R6 cutover on 2026-07-19; complete-frame
|
||||
plumbing, PositionManager interpolation, quantum ownership, and side-channel
|
||||
deletion remain R6 scope.
|
||||
manager order retired in the first R6 cutover on 2026-07-19. The second R6
|
||||
cutover now carries complete CSequence Frames (translation + DAT omega),
|
||||
one incarnation-stable live-object quantum clock across local, remote,
|
||||
Hidden, and projectile components, complete interpolation orientation, and
|
||||
no ObservedOmega/synthetic-turn side channel. It also ports the 96-unit
|
||||
PartArray Active gate plus immediate-active `prepare_to_enter_world` rebasing
|
||||
and preserves live identity across loaded/pending rebucketing. Semantic
|
||||
AnimationDone is in retail's pre-transition manager slot; TS-50 explicitly
|
||||
carries the remaining pose-dependent hook-router delivery delay. The
|
||||
Setup/state-driven `static_animating_objects` workset is now separate as
|
||||
retail requires for both DAT and live physics-Static owners, including
|
||||
unconditional PartArray default installation, direct 30-fps
|
||||
DefaultAnimation, short residency-gap catch-up, and the greater-than-two-
|
||||
second discard. Live static entries reuse the canonical record PartArray and
|
||||
body; `animate_static_object` applies its raw omega without elapsed scaling
|
||||
and commits changed render/effect/collision roots together without zero-omega
|
||||
refloods or Hidden-shadow resurrection.
|
||||
No-animation live remotes remain in the canonical ordinary-object workset,
|
||||
and one animation-optional inbound dispatcher now owns their same retail
|
||||
UpdateMotion funnel and near-correction consumption. `PerformMovement`
|
||||
reactivates its owner before validation. A shared update-thread inbound FIFO
|
||||
now prevents synchronous App callbacks from interleaving packets or frame
|
||||
tails, while exact-record Position/State/Vector/Movement authority versions
|
||||
preserve retail's independent timestamp channels and a narrow velocity token
|
||||
resolves their one overlapping write. Projectile Begin/Complete prediction,
|
||||
teleport placement, landing callbacks, Hidden transitions, and deferred
|
||||
animation hooks all revalidate those exact ownership boundaries. Pose-owner
|
||||
lifetime versions are captured before AnimationDone and checked before each
|
||||
semantic completion and routed hook, closing delete/local-ID reuse during
|
||||
both capture and drain. Direct frame/packet FIFO dispatch and spatial
|
||||
animation update/render traversal are allocation-free after warmup; only
|
||||
real nested work allocates a retained queue node. TS-51 records
|
||||
the remaining shared particle/PhysicsScript tail timing. The ordinary
|
||||
live-object scheduler and generic body transition path now live outside
|
||||
GameWindow; body-backed animations, retained post-Missile bodies, visibility-
|
||||
edge clock/shadow residency (including initial pending materialization),
|
||||
callback-safe cross-cell rebucketing, and teardown all share the canonical
|
||||
live record. Local and authoritative remote collision publication now occurs
|
||||
only after exact-owner spatial revalidation; remote shadows refresh for
|
||||
translation, complete orientation, or cell change. Folding
|
||||
InterpolationManager into the owned
|
||||
PositionManager facade remains the final R6 ownership cleanup.
|
||||
Final 2026-07-20 gate: three independent retail/architecture/adversarial
|
||||
re-reviews are clean; Release build is warning-free; the repository suite is
|
||||
6,446 passed / 5 skipped. A connected locomotion/collision/projectile/
|
||||
teleport visual and performance rebaseline remains the next gate.
|
||||
- **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).**
|
||||
ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL
|
||||
remote entities (players, NPCs, monsters — one funnel, user-approved):
|
||||
|
|
|
|||
|
|
@ -10,10 +10,15 @@ favorite spell bar, connected casting/enchantment effects, and portal-space
|
|||
presentation have passed their single-client visual gates. The corrective bounded
|
||||
render/resource-lifetime integration passed its seven-destination connected
|
||||
stress gate without a crash or cumulative collapse. The remaining M3 visual
|
||||
requirement is the final two-client portal-out/materialize observer gate.
|
||||
requirement is the final two-client portal-out/materialize observer gate. The
|
||||
new R6 object-update cutover also requires one connected locomotion/collision/
|
||||
projectile/teleport visual and performance rebaseline before that observer gate.
|
||||
Carried:
|
||||
#145-residual, #116 slide-response, the remaining R6 object-pipeline cutover
|
||||
(TS-42 manager order retired 2026-07-19), and Track MP0.
|
||||
#145-residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
||||
registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset
|
||||
cutover is automated-test complete 2026-07-20; three independent re-reviews are
|
||||
clean, Release builds with zero warnings, and 6,446 tests pass / 5 skip), and
|
||||
Track MP0.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@
|
|||
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp).
|
||||
**Cross-check:** `src/AcDream.Core/Physics/InterpolationManager.cs` (current port), `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` (cdb-traced).
|
||||
|
||||
> **2026-07-19 correction:** the May audit correctly identified that
|
||||
> `adjust_offset` mutates its `Frame*`, but incorrectly described the result as
|
||||
> translation-only and the old `Vector3` API as equivalent. Retail preserves
|
||||
> the complete relative quaternion produced by `Position::subtract2` unless
|
||||
> `keep_heading` explicitly replaces that rotation with heading zero. R6 now
|
||||
> carries the complete Frame. See
|
||||
> `docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md`. Section 7 is
|
||||
> retained as a historical snapshot of the May port, not current status.
|
||||
|
||||
---
|
||||
|
||||
## 1. Class layout (verbatim from `acclient.h`)
|
||||
|
|
@ -135,7 +144,9 @@ Our current port does duplicate-prune against only the last entry (`_queue.Last`
|
|||
## 4. `InterpolationManager::adjust_offset` @ `0x00555d30`
|
||||
|
||||
Signature: `void adjust_offset(InterpolationManager *this, Frame *arg2, double arg3)`
|
||||
- `arg2` = output Frame (already-zeroed identity Frame from caller `CPhysicsObj::UpdatePartsInternal` @ `0x00512c3c`).
|
||||
- `arg2` = the mutable per-object delta Frame. `CPartArray::Update` may already
|
||||
have written root translation and orientation into it; active interpolation
|
||||
replaces that complete Frame.
|
||||
- `arg3` = frame `dt` in seconds (double).
|
||||
|
||||
### Critical answer: **arg2 is MUTATED IN PLACE.** Not a delta-return.
|
||||
|
|
@ -146,13 +157,18 @@ The last meaningful action (line 353253) is:
|
|||
00555f10 Frame::operator=(arg2, &__return);
|
||||
```
|
||||
|
||||
where `__return` is a Frame whose `m_fOrigin` was just scaled to the per-frame step, and whose rotation is 0 (or kept-heading) (line 353251). The caller composes this into the world position with:
|
||||
where `__return` is the complete relative Frame from `Position::subtract2`.
|
||||
Only `m_fOrigin` is scaled to the per-frame step. Its quaternion remains the
|
||||
target-relative rotation unless `keep_heading` calls `Frame::set_heading(0)`.
|
||||
The caller composes this into the world position with:
|
||||
|
||||
```c
|
||||
00512d22 Frame::combine(arg3 /*world out*/, &this->m_position.frame, &var_40 /*= arg2*/);
|
||||
```
|
||||
|
||||
**So `adjust_offset` writes a translation-only Frame — the caller treats it as an offset Frame to combine with the body's current position.** Our acdream port returns a `Vector3` delta, which the caller adds to the body — equivalent semantics.
|
||||
**So `adjust_offset` writes a complete relative Frame, and the caller combines
|
||||
both its translation and orientation with the body's current Frame.** A
|
||||
`Vector3`-only port loses pitch/roll and is not equivalent.
|
||||
|
||||
### Step-by-step retail flow:
|
||||
|
||||
|
|
@ -238,7 +254,7 @@ if (this->keep_heading != 0) {
|
|||
}
|
||||
|
||||
// ---- Output ----
|
||||
Frame::operator=(arg2, &delta_frame); // OUT: arg2 = translation-only Frame
|
||||
Frame::operator=(arg2, &delta_frame); // OUT: complete relative Frame
|
||||
```
|
||||
|
||||
### NOTE on `progress_quantum`
|
||||
|
|
@ -456,7 +472,7 @@ if (CPhysicsObj::SetPositionSimple(po, &target, 1) == OK_SPE) {
|
|||
| **Stall fail action** | Increments fail counter; only blips when threshold exceeded INSIDE adjust_offset | Calls `NodeCompleted(0)` on stall and pops the head; UseTime does the actual blip | **Architectural gap.** Retail's NodeCompleted(0) on stall pops the head node (advancing the queue) — useful when the head is unreachable but later nodes might be. Our port leaves the head in place. This means a single bad waypoint can cause repeated 5-frame failures rather than skipping past it. |
|
||||
| Blip target on threshold | `_queue.Last.TargetPosition` (tail) | UseTime: tail OR blipto_position OR last-pos-before-velocity-tail | Mostly OK for our use case (only type-1 nodes). |
|
||||
| `transient_state & 1` gate | None | Required | Probably safe in our codebase; file as follow-up. |
|
||||
| AdjustOffset return shape | `Vector3` delta | In-place mutate `Frame*` (translation-only Frame) | Functionally equivalent — caller composes with current position either way. |
|
||||
| AdjustOffset return shape | `Vector3` delta | In-place mutate complete `Frame*` | **Historical R6 gap, fixed 2026-07-19.** The old shape lost target-relative orientation; production now uses `MotionDeltaFrame`. |
|
||||
|
||||
### Concrete actionable changes
|
||||
|
||||
|
|
|
|||
|
|
@ -44,9 +44,9 @@ stage; **LOW** = dormant/textual.
|
|||
| G3 | **update_internal boundary model: clamp-to-`high_frame`/`low_frame` + leftover-time carry, boundary test `floor(frameNum) > high_frame` (i.e. ≥ high+1).** acdream tests `newPos >= maxBoundary - FrameEpsilon` and clamps `_framePosition = maxBoundary - FrameEpsilon` — epsilon-shifted boundary, different clamp target, no strict retail equivalence at exact-integer landings. This is the #61 "link→cycle boundary flash" bug class. | `0x005255d0` body (lines 301839-302235); ACE `Sequence.cs:366-377` (fwd), `:394-406` (rev) | `AnimationSequencer.cs:894,900` (`maxBoundary - FrameEpsilon`) | **BLOCKER** |
|
||||
| G4 | **`safety=64` loop cap** — retail has none; termination comes from `frameTimeElapsed` shrinking + the `frametime == 0` branch returning. Mandate says delete bandaids on cutover. | ACE `Sequence.cs:351-443` (no cap); decomp `0x005255d0` (while-true loop) | `AnimationSequencer.cs:872-874` | MED (delete in R1 core) |
|
||||
| G5 | **AnimDone gate is a LIST-STRUCTURE test, not a node flag.** Retail queues the global `anim_done_hook` singleton when `animDone && hook_obj != null && (anim_list.head_ − 4) != first_cyclic` — i.e. "the old head has been consumed and we're not already in the cyclic tail". acdream pushes `AnimationDoneSentinel` gated on `!_currNode.Value.IsLooping` — a per-node flag that acdream itself invented (retail nodes have no IsLooping). Different gate ⇒ different MotionDone timing. R2's `CheckForCompletedMotions → AnimationDone → MotionDone` chain consumes this signal; it must be retail-exact in R1. | `0x00525943-0x00525968` (verified raw this pass); AnimDoneHook singleton at data `0x0081d9fc`, Execute `0x00526c20` → `Hook_AnimDone 0x0050fda0` → `CPartArray::AnimationDone(1)` (§18a) | `AnimationSequencer.cs:1129` (`AnimationDoneSentinel`), Advance's `!IsLooping` gate (r1-acdream map row "AnimationDone hook") | **HIGH** |
|
||||
| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); `CPhysicsObj::process_hooks` executes them inside `UpdatePositionInternal`, before the transition and manager tail, then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar. R6 processes semantic `AnimationDone` at capture time while retaining pose-dependent presentation delivery until current root/part poses are published. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18); corrected order pinned in `2026-07-19-r6-update-object-order-pseudocode.md` | `AnimationSequencer.cs` pending-hook queue; `AnimationHookFrameQueue.Capture` semantic completion and `Drain` presentation delivery | RETIRED by R6 semantic-order cutover 2026-07-19 |
|
||||
| G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it |
|
||||
| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null` → `apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED |
|
||||
| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); `CPhysicsObj::process_hooks` executes the complete stream inside `UpdatePositionInternal`, before the transition and manager tail, then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar. R6 processes semantic `AnimationDone` at capture time while retaining every other pose-dependent hook until current root/part/equipped-child poses are published. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18); corrected order pinned in `2026-07-19-r6-complete-root-frame-pseudocode.md` | `AnimationSequencer.cs` pending-hook queue; `AnimationHookFrameQueue.Capture` semantic completion and `Drain` presentation delivery | **PARTIAL:** AnimationDone semantic order retired 2026-07-19; non-AnimationDone delivery remains TS-50 |
|
||||
| G7 | **Frame-target root motion.** `update(quantum, Frame*)` accumulates literal velocity and omega plus authored PosFrames into one Frame. Local and remote movement now pass that complete Frame through `PositionManager`, `Frame::combine`, and collision; command-derived translation and manual rotation consumers are gone. | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0`; `Frame::combine 0x005122E0` | `CSequence.ApplyPhysics`; `AnimationSequencer.Advance(dt, Frame)`; `MotionDeltaFrame`; `PlayerMovementController`; `RemotePhysicsUpdater`; `2026-07-19-r6-complete-root-frame-pseudocode.md` | **RETIRED 2026-07-19** |
|
||||
| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` is empty and `frame != null` → `apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no animation). The ported `CSequence.Update` implements that branch and `AnimationSequencer.Advance(dt, Frame)` exposes it to the ordinary-object scheduler. | `0x00525b80` (line 302402, §22) | `CSequence.Update`; `AnimationSequencer.Advance(dt, Frame)`; empty-list/full-Frame scheduler conformance tests | **RETIRED 2026-07-19** |
|
||||
| G9 | **`advance_to_next_animation` uses elapsed-direction plus node-framerate sign gates.** For elapsed ≥ 0, subtract the outgoing pose only when its framerate is negative, step Next/wrap `first_cyclic`, and combine the incoming pose only when its framerate is positive. For elapsed < 0, subtract when outgoing framerate is non-negative, step Previous/wrap list tail, and combine when incoming framerate is negative. Physics inside a selected pose operation uses the separate strict `abs(framerate) > F_EPSILON` gate. Matching v11.4186 assembly and ACE agree; the earlier “four unconditional pose ops” cleanup was wrong. | `0x005252B0`; matching assembly audit 2026-07-19; `r1-ace-sequence.md:70-86` | `CSequence.AdvanceToNextAnimation` + exact-boundary/sign conformance tests | **RETIRED 2026-07-19** |
|
||||
| G10 | **`append_animation` slides `first_cyclic` to the just-appended node on EVERY call.** Retail's "cyclic tail" is always exactly the LAST appended anim (so a multi-anim cycle MotionData loops only its final AnimData node once earlier ones are consumed). acdream sets `_firstCyclic` to the FIRST node of the cycle MotionData. Also retail: `if (curr_anim == null) { curr_anim = head; frame_number = get_starting_frame(head); }` — acdream's equivalents are scattered through `SetCycle`'s rebuild. | `0x00525510` (line 301777, §24) | `AnimationSequencer.cs:634-645` region + `EnqueueMotionData` (r1-acdream map rows "Node list", "add_motion") | **HIGH** — divergent loop membership for multi-anim cycles; also the retail invariant that makes remove_cyclic/apricot correct |
|
||||
| G11 | **The remove-family with curr_anim snap semantics is missing.** Retail: `remove_cyclic_anims` (0x00524e40) deletes `first_cyclic`→tail, snapping `curr_anim` back to prev + `frame_number = get_ending_frame(prev)` (or 0), then `first_cyclic = tail`; `remove_link_animations(n)` (0x00524be0) / `remove_all_link_animations` (0x00524ca0) delete predecessors of `first_cyclic`, snapping `curr_anim` FORWARD to `first_cyclic` + `get_starting_frame`; `clear_animations` (0x00524dc0) full wipe; `apricot` (0x00524b40) trims consumed leading nodes after every update, bounded by `curr_anim`/`first_cyclic`. acdream instead has `ClearCyclicTail` + wholesale queue clears + the invented "stale-head `_currNode` force-relocation" + "Fix B link-skip" — all approximations of what the retail remove-family + apricot do naturally. | lines 301258/301060/301128/301207/300978 (§5-8, §20) | `AnimationSequencer.cs:1311` (`ClearCyclicTail`), `:511`, stale-head relocation + Fix B blocks in `SetCycle` (r1-acdream map rows "Stale-head handling", "Fix B") | **HIGH** — these retire two invented mechanisms |
|
||||
|
|
@ -79,7 +79,7 @@ divergence-register row when touched.
|
|||
| Per-frame crossing walk fires pose+hooks for EVERY integer frame crossed, strict ascending (fwd) / descending (rev) order | `0x005255d0` do/while loops (lines 302006-302056 + reverse mirror) | `AnimationSequencer.cs:910-941` (fwd `lastFrame++` w/ Forward, rev `lastFrame--` w/ Backward) |
|
||||
| Forward node wrap to `first_cyclic` (loop-the-cycle mechanism) | `0x005252b0` @0x005253xx: `GetNext==null → first_cyclic` | `AnimationSequencer.cs:1350-1358` |
|
||||
| Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | Matching v11.4186 assembly `0x00525982-0x00525990` copies `frameTimeElapsed` back then loops; ACE `Sequence.cs:436-442` agrees | `CSequence.UpdateInternal` assigns `timeElapsed = frameTimeElapsed` before looping |
|
||||
| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `ApplyPosFrame(node, idx, reverse:)` fwd/conjugate-reverse (r1-acdream map row "Root motion") — values correct, TARGET wrong (G7: accumulator never drained) |
|
||||
| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `CSequence` writes the caller-supplied root Frame; local and remote physics consume that same Frame (G7 retired) |
|
||||
| `frame_number` floored to int for pose lookup (`get_curr_animframe`/`get_curr_frame_number` shape) | `0x00524970`/`0x005249d0` (§15/17) | `AnimationSequencer.cs:884` (`(int)Math.Floor(_framePosition)`) |
|
||||
| `clear_physics` zeroing before rebuild | `0x00524d50` + `GetObjectSequence`'s `clear_physics; remove_cyclic_anims` pairing @0x005229cf etc. | `ClearPhysics()` called from `SetCycle` (r1-acdream map row "clear_physics") |
|
||||
| `AnimData` speed scaling: only framerate × speed, low/high pass through | `operator* 0x00525d00` (invoked from `add_motion` @0x0052255b) | `LoadAnimNode` (`AnimData.Framerate * speedMod`) |
|
||||
|
|
|
|||
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>
|
||||
public sealed class LocalPlayerOutboundController
|
||||
{
|
||||
private readonly Func<float, Quaternion> _toWireRotation;
|
||||
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
|
||||
|
||||
public LocalPlayerOutboundController(
|
||||
Func<float, Quaternion> toWireRotation,
|
||||
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
|
||||
{
|
||||
_toWireRotation = toWireRotation
|
||||
?? throw new ArgumentNullException(nameof(toWireRotation));
|
||||
_diagnostic = diagnostic
|
||||
?? throw new ArgumentNullException(nameof(diagnostic));
|
||||
}
|
||||
|
|
@ -38,14 +34,13 @@ public sealed class LocalPlayerOutboundController
|
|||
if (session is null || hidden)
|
||||
return;
|
||||
|
||||
Quaternion wireRotation = _toWireRotation(controller.Yaw);
|
||||
if (!controller.TryGetOutboundPosition(
|
||||
wireRotation,
|
||||
out uint wireCellId,
|
||||
out Vector3 wirePosition))
|
||||
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
uint wireCellId = outboundPosition.ObjCellId;
|
||||
Vector3 wirePosition = outboundPosition.Frame.Origin;
|
||||
Quaternion wireRotation = outboundPosition.Frame.Orientation;
|
||||
|
||||
if (movement.ShouldSendMovementEvent)
|
||||
TrySendMovement(session, controller, movement);
|
||||
|
|
@ -82,16 +77,14 @@ public sealed class LocalPlayerOutboundController
|
|||
if (session is null || hidden)
|
||||
return;
|
||||
|
||||
Quaternion wireRotation = _toWireRotation(controller.Yaw);
|
||||
if (!controller.TryGetOutboundPosition(
|
||||
wireRotation,
|
||||
out uint wireCellId,
|
||||
out Vector3 wirePosition))
|
||||
if (!controller.TryGetOutboundPosition(out Position position))
|
||||
{
|
||||
return;
|
||||
}
|
||||
uint wireCellId = position.ObjCellId;
|
||||
Vector3 wirePosition = position.Frame.Origin;
|
||||
Quaternion wireRotation = position.Frame.Orientation;
|
||||
|
||||
var position = new Position(wireCellId, wirePosition, wireRotation);
|
||||
if (!controller.ShouldSendPositionEvent(
|
||||
position,
|
||||
controller.ContactPlane,
|
||||
|
|
@ -136,14 +129,13 @@ public sealed class LocalPlayerOutboundController
|
|||
if (session is null || controller is null)
|
||||
return false;
|
||||
|
||||
Quaternion wireRotation = _toWireRotation(controller.Yaw);
|
||||
if (!controller.TryGetOutboundPosition(
|
||||
wireRotation,
|
||||
out uint wireCellId,
|
||||
out Vector3 wirePosition))
|
||||
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
uint wireCellId = outboundPosition.ObjCellId;
|
||||
Vector3 wirePosition = outboundPosition.Frame.Origin;
|
||||
Quaternion wireRotation = outboundPosition.Frame.Orientation;
|
||||
|
||||
byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0;
|
||||
RawMotionState rawMotionState = BuildRawMotionState(movement);
|
||||
|
|
|
|||
|
|
@ -13,19 +13,27 @@ public sealed class LocalPlayerProjectionController
|
|||
private readonly Func<int> _liveCenterY;
|
||||
private readonly Action<WorldEntity, uint> _syncShadow;
|
||||
private readonly Action<uint, uint> _rebucket;
|
||||
private readonly Func<WorldEntity, bool> _isCurrentVisibleProjection;
|
||||
private readonly Action<WorldEntity> _suspendShadow;
|
||||
|
||||
public LocalPlayerProjectionController(
|
||||
Func<WorldEntity?> resolveEntity,
|
||||
Func<int> liveCenterX,
|
||||
Func<int> liveCenterY,
|
||||
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));
|
||||
_liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX));
|
||||
_liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY));
|
||||
_syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow));
|
||||
_rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket));
|
||||
_isCurrentVisibleProjection = isCurrentVisibleProjection
|
||||
?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection));
|
||||
_suspendShadow = suspendShadow
|
||||
?? throw new ArgumentNullException(nameof(suspendShadow));
|
||||
}
|
||||
|
||||
public void Project(
|
||||
|
|
@ -39,11 +47,10 @@ public sealed class LocalPlayerProjectionController
|
|||
|
||||
entity.SetPosition(movement.RenderPosition);
|
||||
entity.ParentCellId = movement.CellId;
|
||||
entity.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
|
||||
System.Numerics.Vector3.UnitZ,
|
||||
controller.Yaw - MathF.PI / 2f);
|
||||
if (!hidden)
|
||||
_syncShadow(entity, movement.CellId);
|
||||
// Retail's root is a complete Frame. Project the authoritative body
|
||||
// quaternion directly so pitch/roll from a server correction or DAT
|
||||
// root frame cannot be flattened by the presentation layer.
|
||||
entity.Rotation = controller.BodyOrientation;
|
||||
|
||||
uint currentLandblock;
|
||||
if (movement.CellId != 0 && (movement.CellId & 0xFFFFu) >= 0x0100u)
|
||||
|
|
@ -62,7 +69,22 @@ public sealed class LocalPlayerProjectionController
|
|||
|
||||
// The teleport owner alone projects the destination while the local
|
||||
// controller deliberately retains its frozen source cell.
|
||||
if (controller.State != PlayerState.PortalSpace)
|
||||
if (controller.State == PlayerState.PortalSpace)
|
||||
return;
|
||||
|
||||
// SetPositionInternal commits the complete root before changing cell
|
||||
// membership, then replaces collision rows only while the object still
|
||||
// owns a loaded cell. Rebucket is a synchronous callback boundary: it
|
||||
// can move this incarnation to pending or replace the GUID entirely.
|
||||
_rebucket(entity.ServerGuid, currentLandblock);
|
||||
if (hidden
|
||||
|| movement.CellId == 0
|
||||
|| !_isCurrentVisibleProjection(entity))
|
||||
{
|
||||
_suspendShadow(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
_syncShadow(entity, movement.CellId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,12 +156,39 @@ public sealed class PlayerMovementController
|
|||
/// </summary>
|
||||
public PlayerState State { get; set; } = PlayerState.InWorld;
|
||||
|
||||
public float Yaw { get; set; }
|
||||
/// <summary>
|
||||
/// Horizontal projection of the authoritative body quaternion. Assigning
|
||||
/// a yaw is the explicit retail <c>Frame::set_heading</c> seam and therefore
|
||||
/// intentionally replaces pitch/roll; ordinary object ticks never rebuild
|
||||
/// the quaternion from this projection.
|
||||
/// </summary>
|
||||
public float Yaw
|
||||
{
|
||||
get => AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
|
||||
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(_body.Orientation));
|
||||
set
|
||||
{
|
||||
float wrapped = value;
|
||||
while (wrapped > MathF.PI) wrapped -= 2f * MathF.PI;
|
||||
while (wrapped < -MathF.PI) wrapped += 2f * MathF.PI;
|
||||
_body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
|
||||
_body.Orientation,
|
||||
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(wrapped));
|
||||
}
|
||||
}
|
||||
public Vector3 Position => _body.Position;
|
||||
public Vector3 RenderPosition => ComputeRenderPosition();
|
||||
public uint CellId { get; private set; }
|
||||
public AcDream.Core.Physics.Position CellPosition => _body.CellPosition;
|
||||
|
||||
/// <summary>
|
||||
/// True only when the most recent visible or Hidden object update admitted
|
||||
/// at least one complete retail quantum. Presentation uses this to rebuild
|
||||
/// a Hidden part pose after HandleEnterWorld without doing per-render-frame
|
||||
/// work while the object clock is retaining a fragment.
|
||||
/// </summary>
|
||||
internal bool AdvancedObjectQuantumLastTick { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns retail's canonical outbound <c>Position</c>: the physics body's
|
||||
/// carried cell id plus its landblock-local frame origin. Retail
|
||||
|
|
@ -171,14 +198,17 @@ public sealed class PlayerMovementController
|
|||
/// part of the protocol frame.
|
||||
/// </summary>
|
||||
internal bool TryGetOutboundPosition(
|
||||
Quaternion rotation,
|
||||
out uint cellId,
|
||||
out Vector3 localOrigin)
|
||||
out AcDream.Core.Physics.Position outboundPosition)
|
||||
{
|
||||
AcDream.Core.Physics.Position canonical = _body.CellPosition;
|
||||
cellId = canonical.ObjCellId;
|
||||
localOrigin = canonical.Frame.Origin;
|
||||
return PositionFrameValidation.IsValid(cellId, localOrigin, rotation);
|
||||
outboundPosition = new AcDream.Core.Physics.Position(
|
||||
canonical.ObjCellId,
|
||||
canonical.Frame.Origin,
|
||||
_body.Orientation);
|
||||
return PositionFrameValidation.IsValid(
|
||||
outboundPosition.ObjCellId,
|
||||
outboundPosition.Frame.Origin,
|
||||
outboundPosition.Frame.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -324,11 +354,11 @@ public sealed class PlayerMovementController
|
|||
/// Exposed for the network outbound layer to stamp NotePositionSent.</summary>
|
||||
public float SimTimeSeconds => _simTimeSeconds;
|
||||
|
||||
// L.5 retail physics-tick gate (2026-04-30).
|
||||
// R6 retail complete-object scheduler (2026-07-19).
|
||||
//
|
||||
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
|
||||
// MinQuantum (1/30s) sized integration steps, SKIPPING entirely when
|
||||
// accumulated dt is below MinQuantum. The retail debugger trace
|
||||
// Retail's CPhysicsObj::update_object subdivides elapsed time into
|
||||
// MaxQuantum-sized complete object updates plus a remainder, retaining
|
||||
// that remainder while it is at or below MinQuantum. The debugger trace
|
||||
// confirmed this: UpdatePhysicsInternal fires only ~61% as often as
|
||||
// update_object — i.e., retail's effective physics tick rate is 30Hz
|
||||
// even when the renderer runs at 60+Hz.
|
||||
|
|
@ -348,11 +378,16 @@ public sealed class PlayerMovementController
|
|||
//
|
||||
// ACE: PhysicsObj.UpdateObject (Physics.cs).
|
||||
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
|
||||
private float _physicsAccum;
|
||||
private readonly RetailObjectQuantumClock _objectClock;
|
||||
private Vector3 _prevPhysicsPos;
|
||||
private Vector3 _currPhysicsPos;
|
||||
private Func<float, Vector3>? _advanceAnimationRootMotion;
|
||||
private Vector3 _pendingAnimationRootMotion;
|
||||
private Action<float, AcDream.Core.Physics.Motion.MotionDeltaFrame>?
|
||||
_advanceAnimationRootMotion;
|
||||
private Action? _processAnimationHooks;
|
||||
private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
_animationRootMotionScratch = new();
|
||||
private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
_positionManagerDeltaScratch = new();
|
||||
|
||||
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
|
||||
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
|
||||
|
|
@ -418,9 +453,12 @@ public sealed class PlayerMovementController
|
|||
/// </summary>
|
||||
public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; }
|
||||
|
||||
public PlayerMovementController(PhysicsEngine physics)
|
||||
public PlayerMovementController(
|
||||
PhysicsEngine physics,
|
||||
RetailObjectQuantumClock? objectClock = null)
|
||||
{
|
||||
_physics = physics;
|
||||
_objectClock = objectClock ?? new RetailObjectQuantumClock();
|
||||
|
||||
_body = new PhysicsBody
|
||||
{
|
||||
|
|
@ -448,6 +486,7 @@ public sealed class PlayerMovementController
|
|||
// (retail CPhysicsObj::movement_manager); the moveto child binds
|
||||
// later via MoveToFactory (EnterPlayerModeNow / the test rigs).
|
||||
Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion);
|
||||
Movement.ActivatePhysicsObject = ActivateFromMovement;
|
||||
// R3-W4 (A3): the local player's movement is input-driven —
|
||||
// movement_is_autonomous true so apply_current_movement's dual
|
||||
// dispatch routes apply_raw_movement (IsThePlayer && autonomous).
|
||||
|
|
@ -455,6 +494,35 @@ public sealed class PlayerMovementController
|
|||
_body.LastMoveWasAutonomous = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host half of retail <c>MovementManager::PerformMovement</c>'s
|
||||
/// unconditional <c>CPhysicsObj::set_active(1)</c> head. Static objects
|
||||
/// reject activation; an inactive ordinary object rebases its canonical
|
||||
/// object clock before setting the body's transient bit.
|
||||
/// </summary>
|
||||
private void ActivateFromMovement()
|
||||
{
|
||||
if ((_body.State & PhysicsStateFlags.Static) != 0)
|
||||
return;
|
||||
|
||||
_objectClock.Activate();
|
||||
_body.TransientState |= TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies retail's parent/cell-less/Frozen early gate: clear Active and
|
||||
/// advance only wall-clock presentation time. The next eligible frame
|
||||
/// reactivates through <c>set_active(1)</c> and rebases the object clock.
|
||||
/// </summary>
|
||||
internal void SuspendObjectUpdate(float elapsedSeconds)
|
||||
{
|
||||
AdvancedObjectQuantumLastTick = false;
|
||||
if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f)
|
||||
_simTimeSeconds += elapsedSeconds;
|
||||
_objectClock.Deactivate();
|
||||
_body.TransientState &= ~TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins retail instant mouse-look. <c>CameraSet::ToggleMouseLook</c>
|
||||
/// (0x00457490) sends a movement event on both transitions, even before a
|
||||
|
|
@ -834,11 +902,23 @@ public sealed class PlayerMovementController
|
|||
/// and OnWalkable must be present on the local physics body.</summary>
|
||||
internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable;
|
||||
|
||||
/// <summary>R4-V5: body orientation for the <see cref="MoveTo"/>
|
||||
/// manager's position seam (re-derived from <see cref="Yaw"/> every
|
||||
/// Update — heading reads/writes go through Yaw, not this).</summary>
|
||||
/// <summary>R4-V5: complete body orientation for the
|
||||
/// <see cref="MoveTo"/> manager's position seam. <see cref="Yaw"/> is
|
||||
/// only its horizontal projection.</summary>
|
||||
internal Quaternion BodyOrientation => _body.Orientation;
|
||||
|
||||
/// <summary>
|
||||
/// Install a complete authoritative frame rotation (spawn, teleport, or
|
||||
/// server correction) without collapsing it through the yaw projection.
|
||||
/// </summary>
|
||||
internal void SetBodyOrientation(Quaternion orientation)
|
||||
{
|
||||
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||||
_body.Position,
|
||||
_body.Orientation,
|
||||
orientation);
|
||||
}
|
||||
|
||||
/// <summary>R4-V5 wedge fix — the P1 unpack store
|
||||
/// (<c>CPhysics::SetObjectMovement</c> @00509730:
|
||||
/// <c>last_move_was_autonomous = arg7</c>, written on the unpack path
|
||||
|
|
@ -885,15 +965,19 @@ public sealed class PlayerMovementController
|
|||
/// <summary>
|
||||
/// Binds the owning PartArray/CSequence update to the local physics tick.
|
||||
/// The callback runs after this frame's input edges have reached the motion
|
||||
/// table and returns the complete local translation emitted by
|
||||
/// <c>CSequence::update</c>. The controller accumulates sub-quantum deltas
|
||||
/// and consumes them through the same PositionManager frame as retail.
|
||||
/// table and writes the complete local <c>Frame</c> emitted by
|
||||
/// <c>CSequence::update</c>. The callback runs only for admitted object
|
||||
/// quanta; render-frame fragments are retained by the object clock rather
|
||||
/// than advancing PartArray early.
|
||||
/// </summary>
|
||||
public void AttachAnimationRootMotionSource(Func<float, Vector3> advance)
|
||||
public void AttachAnimationRootMotionSource(
|
||||
Action<float, AcDream.Core.Physics.Motion.MotionDeltaFrame> advance,
|
||||
Action? processHooks = null)
|
||||
{
|
||||
_advanceAnimationRootMotion = advance
|
||||
?? throw new ArgumentNullException(nameof(advance));
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
_processAnimationHooks = processHooks;
|
||||
_animationRootMotionScratch.Reset();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1050,7 +1134,9 @@ public sealed class PlayerMovementController
|
|||
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
|
||||
|
||||
// Treat as grounded after a server-side position snap.
|
||||
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
_body.TransientState = TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable
|
||||
| TransientStateFlags.Active;
|
||||
_body.Velocity = Vector3.Zero;
|
||||
|
||||
// #145 Slice 7: idle the motion interpreter on a server snap / teleport arrival.
|
||||
|
|
@ -1094,8 +1180,7 @@ public sealed class PlayerMovementController
|
|||
|
||||
// Reset physics clock so any subsequent update_object calls start fresh.
|
||||
_body.LastUpdateTime = 0.0;
|
||||
_physicsAccum = 0f;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
_objectClock.ResetForEnterWorld();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1109,13 +1194,15 @@ public sealed class PlayerMovementController
|
|||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
|
||||
}
|
||||
|
||||
private Vector3 ComputeRenderPosition()
|
||||
{
|
||||
float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f);
|
||||
float alpha = Math.Clamp(
|
||||
(float)(_objectClock.PendingSeconds / PhysicsBody.MinQuantum),
|
||||
0f,
|
||||
1f);
|
||||
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
|
||||
}
|
||||
|
||||
|
|
@ -1128,30 +1215,56 @@ public sealed class PlayerMovementController
|
|||
/// </summary>
|
||||
public MovementResult TickHidden(float dt, Action? handleTargeting = null)
|
||||
{
|
||||
AdvancedObjectQuantumLastTick = false;
|
||||
if (!float.IsFinite(dt) || dt <= 0f)
|
||||
{
|
||||
return CapturePresentationResult() with
|
||||
{
|
||||
RenderPosition = _body.Position,
|
||||
IsOnGround = _body.OnWalkable,
|
||||
};
|
||||
}
|
||||
|
||||
_simTimeSeconds += dt;
|
||||
_physicsAccum = 0f;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
bool reactivated = _objectClock.Activate();
|
||||
_body.TransientState |= TransientStateFlags.Active;
|
||||
RetailObjectQuantumBatch batch = reactivated
|
||||
? default
|
||||
: _objectClock.Advance(dt);
|
||||
AdvancedObjectQuantumLastTick = batch.Count > 0;
|
||||
if (batch.Discarded)
|
||||
{
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
}
|
||||
|
||||
for (int qi = 0; qi < batch.Count; qi++)
|
||||
{
|
||||
float quantum = batch.GetQuantum(qi);
|
||||
Vector3 previousPosition = _body.Position;
|
||||
bool previousContact = _body.InContact;
|
||||
bool previousOnWalkable = _body.OnWalkable;
|
||||
|
||||
if (PositionManager is { } manager)
|
||||
{
|
||||
var delta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
manager.AdjustOffset(delta, dt);
|
||||
var delta = _positionManagerDeltaScratch;
|
||||
delta.Reset();
|
||||
manager.AdjustOffset(delta, quantum);
|
||||
if (delta.Origin != Vector3.Zero)
|
||||
_body.Position += Vector3.Transform(delta.Origin, _body.Orientation);
|
||||
if (!delta.Orientation.IsIdentity)
|
||||
{
|
||||
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
|
||||
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
|
||||
+ delta.GetHeading());
|
||||
_body.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
Yaw - MathF.PI / 2f);
|
||||
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||||
_body.Position,
|
||||
_body.Orientation,
|
||||
_body.Orientation * delta.Orientation);
|
||||
}
|
||||
}
|
||||
|
||||
// CPhysicsObj::process_hooks remains inside UpdatePositionInternal
|
||||
// even when Hidden suppresses PartArray and physics advancement.
|
||||
_processAnimationHooks?.Invoke();
|
||||
|
||||
if (_body.Position != previousPosition && CellId != 0 && _physics.LandblockCount > 0)
|
||||
{
|
||||
ResolveResult resolved = _physics.ResolveWithTransition(
|
||||
|
|
@ -1181,13 +1294,14 @@ public sealed class PlayerMovementController
|
|||
}
|
||||
|
||||
RetailObjectManagerTail.Run(
|
||||
checkDetection: null,
|
||||
handleTargeting,
|
||||
movementUseTime: Movement.UseTime,
|
||||
partArrayHandleMovement: _motion.CheckForCompletedMotions,
|
||||
positionUseTime: PositionManager is { } positionManager
|
||||
? positionManager.UseTime
|
||||
: null);
|
||||
Movement,
|
||||
_motion.CheckForCompletedMotions,
|
||||
PositionManager);
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
}
|
||||
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
_wasAirborneLastFrame = !_body.OnWalkable;
|
||||
|
|
@ -1212,6 +1326,14 @@ public sealed class PlayerMovementController
|
|||
MovementInput input,
|
||||
Action? handleTargeting = null)
|
||||
{
|
||||
AdvancedObjectQuantumLastTick = false;
|
||||
// Reject a malformed host-frame duration at the controller boundary.
|
||||
// The retail object clock cannot sanitize state that input/jump/yaw
|
||||
// code already mutated; in particular Infinity would never converge
|
||||
// in heading wrap. A rejected frame is a pure presentation read.
|
||||
if (!float.IsFinite(dt) || dt <= 0f)
|
||||
return CapturePresentationResult();
|
||||
|
||||
_simTimeSeconds += dt;
|
||||
|
||||
// Portal-space guard: while teleporting, no input is processed and
|
||||
|
|
@ -1398,87 +1520,12 @@ public sealed class PlayerMovementController
|
|||
_prevRunHeld = input.Run;
|
||||
|
||||
// Retail input reaches MotionTableManager before this object's
|
||||
// CPartArray update. Advance only after the edge dispatch above so a
|
||||
// Ready-to-Walk/Run link contributes its authored displacement on the
|
||||
// same object tick. Sub-quantum render frames accumulate until the
|
||||
// 30 Hz physics gate consumes them below.
|
||||
// CPartArray update. The complete-object scheduler below advances it
|
||||
// once per admitted retail quantum, after every input edge for this
|
||||
// render frame has reached the motion table.
|
||||
bool hasAnimationRootMotion = _advanceAnimationRootMotion is not null;
|
||||
if (_advanceAnimationRootMotion is { } advanceRootMotion)
|
||||
{
|
||||
Vector3 rootDelta = advanceRootMotion(dt);
|
||||
if (_body.OnWalkable)
|
||||
_pendingAnimationRootMotion += rootDelta * ObjectScale;
|
||||
else
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
}
|
||||
|
||||
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
|
||||
// 2026-05-16 — retail-faithful turn rate.
|
||||
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
|
||||
// - CMotionInterp::apply_run_to_command 0x00527be0
|
||||
// multiplies turn_speed by run_turn_factor (1.5) under
|
||||
// HoldKey.Run on TurnRight/TurnLeft commands.
|
||||
// - Base rate ±π/2 rad/s comes from add_motion 0x005224b0
|
||||
// with HasOmega-cleared MotionData fallback.
|
||||
// Effective: walking ≈ 90°/s, running ≈ 135°/s.
|
||||
// Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally
|
||||
// close to retail walking but no run differentiation.
|
||||
// D6.2: local keyboard turn rate now comes from the interpreted turn
|
||||
// state (adjust_motion normalized TurnLeft→TurnRight with sign +
|
||||
// apply_run_to_command ×RunTurnFactor when running). omega.Z =
|
||||
// BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote
|
||||
// path uses (GameWindow ObservedOmega seed). Numerically identical to the
|
||||
// former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged.
|
||||
// R4-V5: runs during a moveto too — the manager's aux-turn steering
|
||||
// and TurnToHeading nodes dispatch TurnRight/TurnLeft through
|
||||
// _DoMotion into the SAME interpreted turn state, so this one
|
||||
// integrator rotates the player for both input and moveto turns.
|
||||
// Mouse-origin turns now enter the same interpreted turn state above.
|
||||
// Packet cadence is carried separately by ShouldSendMovementEvent, so
|
||||
// local mouse sampling cannot flood MoveToState.
|
||||
if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
|
||||
{
|
||||
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
|
||||
* _motion.InterpretedState.TurnSpeed * dt;
|
||||
}
|
||||
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
|
||||
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
||||
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
|
||||
|
||||
// Sync the body's orientation quaternion with our Yaw (rotation about Z).
|
||||
// Convention: Yaw=0 faces +X. Local body +Y is "forward", so we rotate
|
||||
// by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0).
|
||||
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
|
||||
|
||||
// ── 2. Install grounded movement source ───────────────────────────────
|
||||
// Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the
|
||||
// PartArray into one local Frame. Ordinary grounded movement is that
|
||||
// animation-authored displacement, not get_state_velocity(). The
|
||||
// latter remains the explicit fallback for headless/test controllers
|
||||
// that have no attached animation runtime and for jump launch below.
|
||||
if (_body.OnWalkable)
|
||||
{
|
||||
float savedWorldVz = _body.Velocity.Z;
|
||||
if (hasAnimationRootMotion)
|
||||
{
|
||||
// Do not let m_velocityVector move the body a second time.
|
||||
// The pending animation delta is composed with the
|
||||
// PositionManager below and swept through the normal collision
|
||||
// path in the same physics quantum.
|
||||
_body.Velocity = new Vector3(0f, 0f, savedWorldVz);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stateVel = _motion.get_state_velocity();
|
||||
// Preserve LastMoveWasAutonomous: retail stores it at motion
|
||||
// event boundaries, never at this per-tick fallback write.
|
||||
_body.set_local_velocity(
|
||||
new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
|
||||
autonomous: _body.LastMoveWasAutonomous);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Jump (charged) ─────────────────────────────────────────────────
|
||||
// ── 1. Jump input (charged) ───────────────────────────────────────────
|
||||
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
|
||||
// Release to execute: jump(extent) validates + sets JumpExtent,
|
||||
// then LeaveGround() applies the scaled velocity via GetLeaveGroundVelocity.
|
||||
|
|
@ -1540,69 +1587,91 @@ public sealed class PlayerMovementController
|
|||
_jumpExtent = 0f;
|
||||
}
|
||||
|
||||
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
|
||||
//
|
||||
// L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj::
|
||||
// update_object skips integration when accumulated dt is below
|
||||
// MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz
|
||||
// render. We accumulate per-frame dt and only integrate (with the
|
||||
// accumulated dt) when the threshold is reached. See _physicsAccum
|
||||
// declaration for the full retail trace evidence.
|
||||
var preIntegratePos = _body.Position;
|
||||
bool physicsTickRan = false;
|
||||
float lastTickDt = 0f; // the dt actually integrated this frame (for cached_velocity)
|
||||
Vector3 oldTickEndPos = _currPhysicsPos;
|
||||
_physicsAccum += dt;
|
||||
|
||||
if (_physicsAccum > PhysicsBody.HugeQuantum)
|
||||
// ── 2. Run admitted complete-object quanta ────────────────────────────
|
||||
// CPhysicsObj::update_object (0x00515D10) retains a remainder at or
|
||||
// below MinQuantum, splits elapsed time above MaxQuantum, and discards
|
||||
// stale gaps above HugeQuantum. Every admitted quantum executes the
|
||||
// whole object update below; animation never runs on a render-only
|
||||
// fragment.
|
||||
bool reactivated = _objectClock.Activate();
|
||||
_body.TransientState |= TransientStateFlags.Active;
|
||||
RetailObjectQuantumBatch quantumBatch = reactivated
|
||||
? default
|
||||
: _objectClock.Advance(dt);
|
||||
AdvancedObjectQuantumLastTick = quantumBatch.Count > 0;
|
||||
bool justLanded = false;
|
||||
if (quantumBatch.Discarded)
|
||||
{
|
||||
// Stale frame (debugger break, GC pause). Discard accumulated dt.
|
||||
_physicsAccum = 0f;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
}
|
||||
else if (_physicsAccum >= PhysicsBody.MinQuantum)
|
||||
|
||||
for (int qi = 0; qi < quantumBatch.Count; qi++)
|
||||
{
|
||||
// Integrate accumulated dt, clamped to MaxQuantum so a long
|
||||
// pause doesn't produce one giant integration step.
|
||||
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
|
||||
lastTickDt = tickDt;
|
||||
float tickDt = quantumBatch.GetQuantum(qi);
|
||||
|
||||
// R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) —
|
||||
// PositionManager::adjust_offset (@0x00512d0e) composes the
|
||||
// sticky steer into this quantum's motion BEFORE
|
||||
// UpdatePhysicsInternal, so the transition sweep below resolves
|
||||
// it like any other movement (preIntegratePos was captured
|
||||
// above). The delta's Origin is mover-LOCAL (rotate out by the
|
||||
// body orientation); the heading is RELATIVE and writes Yaw —
|
||||
// the authoritative facing the body quaternion is re-derived
|
||||
// from every frame (a quaternion-only write would be clobbered).
|
||||
// Seed the SAME frame that PositionManager receives. Retail's
|
||||
// Sticky interpolation deliberately replaces PartArray root
|
||||
// displacement rather than adding a second movement source.
|
||||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
if (hasAnimationRootMotion && _body.OnWalkable)
|
||||
pmDelta.Origin = _pendingAnimationRootMotion;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
// CPhysicsObj::UpdatePositionInternal (0x00512C30): visible objects
|
||||
// advance their PartArray first. The complete Frame survives; only its
|
||||
// origin is scaled while OnWalkable or zeroed while airborne.
|
||||
var pmDelta = _positionManagerDeltaScratch;
|
||||
pmDelta.Reset();
|
||||
if (_advanceAnimationRootMotion is { } advanceRootMotion)
|
||||
{
|
||||
_animationRootMotionScratch.Reset();
|
||||
advanceRootMotion(tickDt, _animationRootMotionScratch);
|
||||
pmDelta.Origin = _body.OnWalkable
|
||||
? _animationRootMotionScratch.Origin * ObjectScale
|
||||
: Vector3.Zero;
|
||||
pmDelta.Orientation = _animationRootMotionScratch.Orientation;
|
||||
}
|
||||
else if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
|
||||
{
|
||||
// AP-77: explicit no-PartArray/headless fallback. Production
|
||||
// humanoids consume the DAT-authored complete CSequence Frame.
|
||||
Yaw -= 1.5f
|
||||
* _motion.InterpretedState.TurnSpeed
|
||||
* tickDt;
|
||||
}
|
||||
|
||||
if (_body.OnWalkable)
|
||||
{
|
||||
float savedWorldVz = _body.Velocity.Z;
|
||||
if (hasAnimationRootMotion)
|
||||
{
|
||||
_body.Velocity = new Vector3(0f, 0f, savedWorldVz);
|
||||
}
|
||||
else
|
||||
{
|
||||
Vector3 stateVelocity = _motion.get_state_velocity();
|
||||
_body.set_local_velocity(
|
||||
new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz),
|
||||
autonomous: _body.LastMoveWasAutonomous);
|
||||
}
|
||||
}
|
||||
|
||||
var preIntegratePos = _body.Position;
|
||||
Vector3 oldTickEndPos = _currPhysicsPos;
|
||||
|
||||
// PositionManager::adjust_offset (0x00512D0E) mutates the SAME
|
||||
// complete Frame after PartArray. Interpolation may replace it;
|
||||
// Sticky/Constraint then compose according to their retail rules.
|
||||
PositionManager?.AdjustOffset(pmDelta, tickDt);
|
||||
if (pmDelta.Origin != Vector3.Zero)
|
||||
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
|
||||
if (!pmDelta.Orientation.IsIdentity)
|
||||
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
|
||||
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
|
||||
+ pmDelta.GetHeading());
|
||||
{
|
||||
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||||
_body.Position,
|
||||
_body.Orientation,
|
||||
_body.Orientation * pmDelta.Orientation);
|
||||
}
|
||||
|
||||
_body.calc_acceleration();
|
||||
_body.UpdatePhysicsInternal(tickDt);
|
||||
_physicsAccum -= tickDt;
|
||||
physicsTickRan = true;
|
||||
}
|
||||
// Else: dt below MinQuantum threshold — skip integration. Position
|
||||
// and velocity remain unchanged; Resolve below runs as a zero-distance
|
||||
// sphere sweep (no collision possible) and the rest of the frame
|
||||
// (motion commands, animation, return) runs normally.
|
||||
|
||||
// Retail process_hooks is the final UpdatePositionInternal step:
|
||||
// after physics, before the transition and manager tail.
|
||||
_processAnimationHooks?.Invoke();
|
||||
var postIntegratePos = _body.Position;
|
||||
|
||||
// retail UpdateObjectInternal (pc:283657): the transition + handle_all_collisions are
|
||||
|
|
@ -1613,7 +1682,7 @@ public sealed class PlayerMovementController
|
|||
// or it re-zeros the gravity velocity and the body re-wedges instead of falling off.
|
||||
bool candidateMoved = postIntegratePos != preIntegratePos;
|
||||
|
||||
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
|
||||
// ── 3. Collision resolution via CTransition sphere-sweep ─────────────
|
||||
// The Transition system subdivides the movement from pre→post into
|
||||
// sphere-radius steps, testing terrain collision at each step.
|
||||
// Falls back to simple Z-snap if transition fails.
|
||||
|
|
@ -1673,8 +1742,8 @@ public sealed class PlayerMovementController
|
|||
|
||||
// cached_velocity = realized displacement / dt (retail's SEPARATE reporting/DR value,
|
||||
// pc:005158cb-5158ff; not fed back into the integrator velocity).
|
||||
_body.CachedVelocity = (candidateMoved && physicsTickRan && lastTickDt > 0f)
|
||||
? (resolveResult.Position - preIntegratePos) / lastTickDt
|
||||
_body.CachedVelocity = candidateMoved
|
||||
? (resolveResult.Position - preIntegratePos) / tickDt
|
||||
: Vector3.Zero;
|
||||
|
||||
// Capture prev contact/walkable BEFORE committing the new state — retail
|
||||
|
|
@ -1683,11 +1752,8 @@ public sealed class PlayerMovementController
|
|||
bool prevOnWalkable = _body.OnWalkable;
|
||||
|
||||
_body.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position);
|
||||
if (physicsTickRan)
|
||||
{
|
||||
_prevPhysicsPos = oldTickEndPos;
|
||||
_currPhysicsPos = _body.Position;
|
||||
}
|
||||
|
||||
// SetPositionInternal contact determination (pc:283468-510). acdream's resolver
|
||||
// reports IsOnGround even during an UPWARD jump (it always step-downs), so the
|
||||
|
|
@ -1696,7 +1762,7 @@ public sealed class PlayerMovementController
|
|||
// landing state is committed before any reflect — this ordering plus the ungated
|
||||
// small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral
|
||||
// (the old code reflected FIRST, so the reflected +Z defeated the landing gate).
|
||||
bool justLanded = false;
|
||||
bool landedThisQuantum = false;
|
||||
if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
|
||||
{
|
||||
bool wasAirborne = !_body.OnWalkable;
|
||||
|
|
@ -1715,7 +1781,7 @@ public sealed class PlayerMovementController
|
|||
// (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the
|
||||
// airborne UseTime contact gate. LeaveGround has NO moveto side (§2e).
|
||||
Movement.HitGround();
|
||||
justLanded = true;
|
||||
landedThisQuantum = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
@ -1746,25 +1812,24 @@ public sealed class PlayerMovementController
|
|||
_motion.LeaveGround();
|
||||
|
||||
_wasAirborneLastFrame = !_body.OnWalkable;
|
||||
justLanded |= landedThisQuantum;
|
||||
UpdateCellId(resolveResult.CellId, "resolver");
|
||||
|
||||
// Named retail CPhysicsObj::UpdateObjectInternal (0x005156B0):
|
||||
// process_hooks has already completed inside UpdatePositionInternal,
|
||||
// then the transition commits, and only then does the ordered manager
|
||||
// tail run. DetectionManager is not ported yet, so its slot is absent.
|
||||
// PositionManager's clock stays tied to a consumed physics quantum;
|
||||
// the remaining per-render versus per-quantum unification belongs to
|
||||
// the R6 object scheduler rather than this ordering cutover.
|
||||
// tail runs in its exact Detection/Target/Movement/PartArray/
|
||||
// Position order. DetectionManager is not ported, so its slot is
|
||||
// presently null.
|
||||
RetailObjectManagerTail.Run(
|
||||
checkDetection: null,
|
||||
handleTargeting,
|
||||
movementUseTime: Movement.UseTime,
|
||||
partArrayHandleMovement: _motion.CheckForCompletedMotions,
|
||||
positionUseTime: physicsTickRan && PositionManager is { } positionManager
|
||||
? positionManager.UseTime
|
||||
: null);
|
||||
Movement,
|
||||
_motion.CheckForCompletedMotions,
|
||||
PositionManager);
|
||||
|
||||
// ── 6. Determine outbound motion commands ─────────────────────────────
|
||||
}
|
||||
|
||||
// ── 4. Determine outbound motion commands ─────────────────────────────
|
||||
uint? outForwardCmd = null;
|
||||
float? outForwardSpeed = null;
|
||||
uint? outSidestepCmd = null;
|
||||
|
|
|
|||
|
|
@ -21,18 +21,33 @@ public sealed class RetailLocalPlayerFrameController
|
|||
private readonly Action<PlayerMovementController, MovementResult, bool> _project;
|
||||
private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork;
|
||||
private readonly Action<PlayerMovementController, bool> _sendPostNetwork;
|
||||
private readonly Func<AcDream.Core.Physics.RetailObjectClockDisposition>
|
||||
_objectClockDisposition;
|
||||
|
||||
private AdvancedFrame? _advancedFrame;
|
||||
|
||||
private readonly record struct AdvancedFrame(
|
||||
PlayerMovementController Controller,
|
||||
MovementResult Movement);
|
||||
MovementResult Movement,
|
||||
bool ObjectAdvanced,
|
||||
bool Hidden,
|
||||
bool ObjectQuantumAdvanced);
|
||||
|
||||
public readonly record struct PresentationFrame(
|
||||
MovementResult Movement,
|
||||
bool Hidden,
|
||||
bool AdvancedBeforeNetwork);
|
||||
|
||||
/// <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(
|
||||
Func<bool> canPresentPlayer,
|
||||
Func<PlayerMovementController?> getController,
|
||||
|
|
@ -42,7 +57,9 @@ public sealed class RetailLocalPlayerFrameController
|
|||
Func<bool> isHidden,
|
||||
Action<PlayerMovementController, MovementResult, bool> project,
|
||||
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
|
||||
Action<PlayerMovementController, bool> sendPostNetwork)
|
||||
Action<PlayerMovementController, bool> sendPostNetwork,
|
||||
Func<AcDream.Core.Physics.RetailObjectClockDisposition>?
|
||||
objectClockDisposition = null)
|
||||
{
|
||||
_canPresentPlayer = canPresentPlayer
|
||||
?? throw new ArgumentNullException(nameof(canPresentPlayer));
|
||||
|
|
@ -62,6 +79,8 @@ public sealed class RetailLocalPlayerFrameController
|
|||
?? throw new ArgumentNullException(nameof(sendPreNetwork));
|
||||
_sendPostNetwork = sendPostNetwork
|
||||
?? throw new ArgumentNullException(nameof(sendPostNetwork));
|
||||
_objectClockDisposition = objectClockDisposition
|
||||
?? (() => AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -75,16 +94,54 @@ public sealed class RetailLocalPlayerFrameController
|
|||
if (!_canPresentPlayer() || controller is null)
|
||||
return;
|
||||
|
||||
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
|
||||
{
|
||||
bool rejectedHidden = _isHidden();
|
||||
MovementResult rejected = controller.CapturePresentationResult();
|
||||
_project(controller, rejected, rejectedHidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
rejected,
|
||||
ObjectAdvanced: false,
|
||||
Hidden: rejectedHidden,
|
||||
ObjectQuantumAdvanced: false);
|
||||
return;
|
||||
}
|
||||
|
||||
controller.LocalEntityId = _resolveLocalEntityId();
|
||||
|
||||
bool hidden = _isHidden();
|
||||
if (_objectClockDisposition()
|
||||
is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend)
|
||||
{
|
||||
// CPhysicsObj::update_object returns before advancing time for a
|
||||
// Frozen or cell-less object. Keep the retained projection live,
|
||||
// but emit neither input actions nor AutonomousPosition from a
|
||||
// physics tick that retail did not run.
|
||||
controller.SuspendObjectUpdate(deltaSeconds);
|
||||
MovementResult paused = controller.CapturePresentationResult();
|
||||
_project(controller, paused, hidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
paused,
|
||||
ObjectAdvanced: false,
|
||||
Hidden: hidden,
|
||||
ObjectQuantumAdvanced: false);
|
||||
return;
|
||||
}
|
||||
|
||||
MovementResult movement = hidden
|
||||
? controller.TickHidden(deltaSeconds, _handleTargeting)
|
||||
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
|
||||
|
||||
_project(controller, movement, hidden);
|
||||
_sendPreNetwork(controller, movement, hidden);
|
||||
_advancedFrame = new AdvancedFrame(controller, movement);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
movement,
|
||||
ObjectAdvanced: true,
|
||||
Hidden: hidden,
|
||||
ObjectQuantumAdvanced: controller.AdvancedObjectQuantumLastTick);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -106,7 +163,15 @@ public sealed class RetailLocalPlayerFrameController
|
|||
advanced.Movement,
|
||||
controller);
|
||||
_project(controller, movement, hidden);
|
||||
_advancedFrame = new AdvancedFrame(controller, movement);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
movement,
|
||||
advanced.ObjectAdvanced,
|
||||
advanced.Hidden,
|
||||
advanced.ObjectQuantumAdvanced);
|
||||
|
||||
if (!advanced.ObjectAdvanced)
|
||||
return;
|
||||
}
|
||||
|
||||
_sendPostNetwork(controller, hidden);
|
||||
|
|
@ -131,7 +196,10 @@ public sealed class RetailLocalPlayerFrameController
|
|||
MovementResult movement = RefreshSpatialFields(
|
||||
advanced.Movement,
|
||||
controller);
|
||||
frame = new PresentationFrame(movement, hidden, AdvancedBeforeNetwork: true);
|
||||
frame = new PresentationFrame(
|
||||
movement,
|
||||
hidden,
|
||||
AdvancedBeforeNetwork: advanced.ObjectAdvanced);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
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.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
|
|
@ -30,26 +31,34 @@ internal sealed class ProjectileController
|
|||
internal Runtime(
|
||||
ushort generation,
|
||||
PhysicsBody body,
|
||||
ProjectileCollisionSphere collisionSphere,
|
||||
bool hasPartArray)
|
||||
ProjectileCollisionSphere collisionSphere)
|
||||
{
|
||||
Generation = generation;
|
||||
Body = body;
|
||||
CollisionSphere = collisionSphere;
|
||||
HasPartArray = hasPartArray;
|
||||
}
|
||||
|
||||
internal ushort Generation { get; }
|
||||
public PhysicsBody Body { get; }
|
||||
internal ProjectileCollisionSphere CollisionSphere { get; }
|
||||
internal bool HasPartArray { get; }
|
||||
internal ulong PredictionAuthorityVersion { get; private set; }
|
||||
internal void InvalidatePrediction() =>
|
||||
PredictionAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal readonly record struct QuantumStep(
|
||||
LiveEntityRecord Record,
|
||||
Runtime Runtime,
|
||||
WorldEntity Entity,
|
||||
ProjectileQuantumPreparation Preparation,
|
||||
ulong PredictionAuthorityVersion);
|
||||
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ProjectilePhysicsStepper _stepper;
|
||||
private readonly ShadowObjectRegistry _shadows;
|
||||
private readonly Func<uint, Setup?> _setupResolver;
|
||||
private readonly Action<WorldEntity> _publishRootPose;
|
||||
private readonly Func<(int X, int Y)> _liveCenterProvider;
|
||||
private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new();
|
||||
private double _lastFiniteGameTime;
|
||||
|
||||
|
|
@ -57,7 +66,8 @@ internal sealed class ProjectileController
|
|||
LiveEntityRuntime liveEntities,
|
||||
PhysicsEngine physicsEngine,
|
||||
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));
|
||||
ArgumentNullException.ThrowIfNull(physicsEngine);
|
||||
|
|
@ -65,6 +75,7 @@ internal sealed class ProjectileController
|
|||
_shadows = physicsEngine.ShadowObjects;
|
||||
_setupResolver = setupResolver ?? (_ => null);
|
||||
_publishRootPose = publishRootPose ?? (_ => { });
|
||||
_liveCenterProvider = liveCenterProvider ?? (() => (0, 0));
|
||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +137,9 @@ internal sealed class ProjectileController
|
|||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
if (!_liveEntities.TryGetRecord(record.ServerGuid, out var liveRecord)
|
||||
|| !ReferenceEquals(liveRecord, record)
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
return false;
|
||||
|
||||
if (record.ProjectileRuntime is Runtime retained)
|
||||
|
|
@ -158,12 +171,13 @@ internal sealed class ProjectileController
|
|||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
PhysicsBody body;
|
||||
if (record.RemoteMotionRuntime is { } remote)
|
||||
if (record.PhysicsBody is { } sharedBody)
|
||||
{
|
||||
// Retail owns one CPhysicsObj. If a non-missile incarnation
|
||||
// already created its MovementManager, classification adopts that
|
||||
// same body instead of replacing it.
|
||||
body = remote.Body;
|
||||
// already created its MovementManager or entered the Physics-Static
|
||||
// animation workset, classification adopts that same body instead
|
||||
// of replacing it or replaying CreateObject vectors.
|
||||
body = sharedBody;
|
||||
uint currentCellId = record.FullCellId;
|
||||
Vector3 currentCellLocal = CellLocalFromWorld(
|
||||
body.Position,
|
||||
|
|
@ -219,7 +233,15 @@ internal sealed class ProjectileController
|
|||
$"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector.");
|
||||
return false;
|
||||
}
|
||||
body = new PhysicsBody
|
||||
// Claim the incarnation's one CPhysicsObj before Rebucket can
|
||||
// publish visibility callbacks. A callback that also needs a body
|
||||
// must observe and adopt this same instance, never race a private
|
||||
// projectile candidate into SetProjectileRuntime.
|
||||
body = _liveEntities.GetOrCreatePhysicsBody(
|
||||
record.ServerGuid,
|
||||
_ =>
|
||||
{
|
||||
var created = new PhysicsBody
|
||||
{
|
||||
State = record.FinalPhysicsState,
|
||||
Friction = NormalizeFriction(
|
||||
|
|
@ -229,28 +251,51 @@ internal sealed class ProjectileController
|
|||
Orientation = entity.Rotation,
|
||||
LastUpdateTime = currentTime,
|
||||
};
|
||||
SetVelocity(body, velocity, currentTime);
|
||||
body.Omega = omega;
|
||||
body.SnapToCell(
|
||||
SetVelocity(created, velocity, currentTime);
|
||||
created.Omega = omega;
|
||||
created.SnapToCell(
|
||||
wirePosition.LandblockId,
|
||||
entity.Position,
|
||||
new Vector3(
|
||||
wirePosition.PositionX,
|
||||
wirePosition.PositionY,
|
||||
wirePosition.PositionZ));
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
uint canonicalCellId = body.CellPosition.ObjCellId;
|
||||
entity.SetPosition(body.Position);
|
||||
entity.Rotation = body.Orientation;
|
||||
entity.ParentCellId = canonicalCellId;
|
||||
_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId);
|
||||
if (!_liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId)
|
||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out var currentRecord)
|
||||
|| !ReferenceEquals(currentRecord, record)
|
||||
|| !ReferenceEquals(currentRecord.WorldEntity, entity)
|
||||
|| !ReferenceEquals(currentRecord.PhysicsBody, body)
|
||||
|| (currentRecord.FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rebucket publishes projection callbacks synchronously. A callback
|
||||
// can re-enter classification for this same incarnation and install
|
||||
// the one projectile component before this outer call resumes. Adopt
|
||||
// that component; never replace it with a second Runtime that merely
|
||||
// happens to share the canonical CPhysicsObj body.
|
||||
if (currentRecord.ProjectileRuntime is Runtime concurrentRuntime)
|
||||
return ReferenceEquals(concurrentRuntime.Body, body);
|
||||
|
||||
// A newer Position can legally arrive from a projection callback
|
||||
// while this independent State(Missile) classification is in flight.
|
||||
// Both packets must win: install the component around the current
|
||||
// canonical body frame, never the pre-callback cell captured above.
|
||||
canonicalCellId = body.CellPosition.ObjCellId;
|
||||
|
||||
var runtime = new Runtime(
|
||||
record.Generation,
|
||||
body,
|
||||
sphere,
|
||||
entity.MeshRefs.Count > 0);
|
||||
sphere);
|
||||
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
|
||||
|
||||
if (HasVisibleCell(record) && !IsHidden(record))
|
||||
|
|
@ -258,8 +303,8 @@ internal sealed class ProjectileController
|
|||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
canonicalCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
|
|
@ -282,13 +327,34 @@ internal sealed class ProjectileController
|
|||
}
|
||||
|
||||
internal bool ApplyAuthoritativeVector(
|
||||
uint serverGuid,
|
||||
LiveEntityRecord expectedRecord,
|
||||
Vector3 velocity,
|
||||
Vector3 angularVelocity,
|
||||
double currentTime) =>
|
||||
ApplyAuthoritativeVector(
|
||||
expectedRecord,
|
||||
expectedRecord.VectorAuthorityVersion,
|
||||
expectedRecord.VelocityAuthorityVersion,
|
||||
velocity,
|
||||
angularVelocity,
|
||||
currentTime);
|
||||
|
||||
internal bool ApplyAuthoritativeVector(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedVectorAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
Vector3 velocity,
|
||||
Vector3 angularVelocity,
|
||||
double currentTime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
uint serverGuid = expectedRecord.ServerGuid;
|
||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime))
|
||||
return false;
|
||||
if (!ReferenceEquals(record, expectedRecord)
|
||||
|| record.VectorAuthorityVersion != expectedVectorAuthorityVersion
|
||||
|| record.VelocityAuthorityVersion != expectedVelocityAuthorityVersion)
|
||||
return false;
|
||||
|
||||
// Once Missile is cleared, an existing MovementManager owns ordinary
|
||||
// jump/vector side effects (Airborne, gravity, LeaveGround) on this
|
||||
|
|
@ -309,23 +375,48 @@ internal sealed class ProjectileController
|
|||
}
|
||||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
SetVelocity(runtime.Body, velocity, currentTime);
|
||||
runtime.Body.Omega = angularVelocity;
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)
|
||||
&& !IsHidden(record))
|
||||
Deactivate(runtime.Body);
|
||||
runtime.InvalidatePrediction();
|
||||
if (!_liveEntities.TryCommitAuthoritativeVector(
|
||||
record,
|
||||
runtime.Body,
|
||||
velocity,
|
||||
angularVelocity,
|
||||
currentTime))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool ApplyAuthoritativeState(
|
||||
uint serverGuid,
|
||||
LiveEntityRecord expectedRecord,
|
||||
PhysicsStateFlags state,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY) =>
|
||||
ApplyAuthoritativeState(
|
||||
expectedRecord,
|
||||
expectedRecord.StateAuthorityVersion,
|
||||
state,
|
||||
currentTime,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
|
||||
internal bool ApplyAuthoritativeState(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedStateAuthorityVersion,
|
||||
PhysicsStateFlags state,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
uint serverGuid = expectedRecord.ServerGuid;
|
||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
|
||||
return false;
|
||||
if (!ReferenceEquals(record, expectedRecord)
|
||||
|| record.StateAuthorityVersion != expectedStateAuthorityVersion)
|
||||
return false;
|
||||
|
||||
bool validClock = double.IsFinite(currentTime);
|
||||
double effectiveClock = validClock
|
||||
|
|
@ -339,6 +430,7 @@ internal sealed class ProjectileController
|
|||
|
||||
if (record.ProjectileRuntime is Runtime runtime)
|
||||
{
|
||||
runtime.InvalidatePrediction();
|
||||
bool wasMissile =
|
||||
(runtime.Body.State & PhysicsStateFlags.Missile) != 0;
|
||||
runtime.Body.State = state;
|
||||
|
|
@ -398,16 +490,46 @@ internal sealed class ProjectileController
|
|||
/// streaming origin changes never leak into the canonical cell frame.
|
||||
/// </summary>
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
uint serverGuid,
|
||||
LiveEntityRecord expectedRecord,
|
||||
Vector3 worldPosition,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
Vector3 velocity,
|
||||
uint fullCellId,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY) =>
|
||||
ApplyAuthoritativePosition(
|
||||
expectedRecord,
|
||||
expectedRecord.PositionAuthorityVersion,
|
||||
expectedRecord.VelocityAuthorityVersion,
|
||||
worldPosition,
|
||||
cellLocalPosition,
|
||||
orientation,
|
||||
velocity,
|
||||
fullCellId,
|
||||
currentTime,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
|
||||
internal bool ApplyAuthoritativePosition(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedPositionAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
Vector3 worldPosition,
|
||||
Vector3 cellLocalPosition,
|
||||
Quaternion orientation,
|
||||
Vector3 velocity,
|
||||
uint fullCellId,
|
||||
double currentTime,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
uint serverGuid = expectedRecord.ServerGuid;
|
||||
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)
|
||||
|| !ReferenceEquals(record, expectedRecord)
|
||||
|| record.PositionAuthorityVersion != expectedPositionAuthorityVersion
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
|
||||
|| record.WorldEntity is not { } entity)
|
||||
return false;
|
||||
|
|
@ -415,6 +537,7 @@ internal sealed class ProjectileController
|
|||
if (!double.IsFinite(currentTime)
|
||||
|| !IsFinite(worldPosition)
|
||||
|| !IsFinite(cellLocalPosition)
|
||||
|| !IsFinite(velocity)
|
||||
|| !PositionFrameValidation.IsValid(
|
||||
fullCellId,
|
||||
cellLocalPosition,
|
||||
|
|
@ -427,20 +550,53 @@ internal sealed class ProjectileController
|
|||
_lastFiniteGameTime = currentTime;
|
||||
|
||||
PhysicsBody body = runtime.Body;
|
||||
runtime.InvalidatePrediction();
|
||||
bool wasInWorld = body.InWorld;
|
||||
body.Orientation = orientation;
|
||||
body.SnapToCell(fullCellId, worldPosition, cellLocalPosition);
|
||||
body.State = record.FinalPhysicsState;
|
||||
// PositionPack::UnPack initializes an absent velocity to zero and
|
||||
// MoveOrTeleport installs that exact vector through set_velocity.
|
||||
// Apply it to this retained CPhysicsObj before any callback can
|
||||
// displace the incarnation; a replacement record owns another body.
|
||||
if (record.VelocityAuthorityVersion == expectedVelocityAuthorityVersion
|
||||
&& !_liveEntities.TryCommitAuthoritativeVelocity(
|
||||
record,
|
||||
body,
|
||||
velocity,
|
||||
currentTime))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
uint canonicalCellId = body.CellPosition.ObjCellId;
|
||||
|
||||
entity.SetPosition(worldPosition);
|
||||
entity.Rotation = orientation;
|
||||
entity.ParentCellId = canonicalCellId;
|
||||
_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId);
|
||||
if (!_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId)
|
||||
|| !TryGetCurrent(serverGuid, out LiveEntityRecord afterRebucket, out Runtime afterRuntime)
|
||||
|| !ReferenceEquals(afterRebucket, record)
|
||||
|| !ReferenceEquals(afterRuntime, runtime)
|
||||
|| !ReferenceEquals(afterRebucket.WorldEntity, entity)
|
||||
|| afterRebucket.PositionAuthorityVersion
|
||||
!= expectedPositionAuthorityVersion)
|
||||
{
|
||||
// This packet was a projectile correction when admitted. A
|
||||
// re-entrant observer displaced that incarnation; report handled
|
||||
// so the generic remote path cannot mutate the replacement.
|
||||
return true;
|
||||
}
|
||||
if (HasVisibleCell(record) && !IsHidden(record))
|
||||
{
|
||||
if (!wasInWorld)
|
||||
{
|
||||
// prepare_to_enter_world rebases the CPhysicsObj timestamp in
|
||||
// the same operation that restores Active. The record clock is
|
||||
// canonical, but keep this legacy body field synchronized for
|
||||
// the absolute-time Core API and diagnostics.
|
||||
body.LastUpdateTime = currentTime;
|
||||
Activate(body, currentTime);
|
||||
}
|
||||
body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
|
|
@ -466,13 +622,16 @@ internal sealed class ProjectileController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Movement packets use the ordinary MovementManager/animation funnel on
|
||||
/// this same body. The generic remote translation tick consults this gate
|
||||
/// so only the projectile integrator commits its root frame.
|
||||
/// Selects the retained body's ordinary-physics owner. Missile
|
||||
/// classification always uses the projectile transition path. If the
|
||||
/// authoritative Missile bit later clears, that same body still owns
|
||||
/// ordinary physics until a RemoteMotion runtime exists to take it over.
|
||||
/// Component lifetime and classification are deliberately separate.
|
||||
/// </summary>
|
||||
internal bool HandlesMovement(uint serverGuid) =>
|
||||
TryGetCurrent(serverGuid, out LiveEntityRecord record, out _)
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0;
|
||||
&& ((record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0
|
||||
|| record.RemoteMotionRuntime is null);
|
||||
|
||||
internal bool TryGetBody(uint serverGuid, out PhysicsBody body)
|
||||
{
|
||||
|
|
@ -501,6 +660,22 @@ internal sealed class ProjectileController
|
|||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
Vector3? playerWorldPosition)
|
||||
{
|
||||
double elapsed = currentTime - _lastFiniteGameTime;
|
||||
Tick(
|
||||
currentTime,
|
||||
double.IsFinite(elapsed) && elapsed > 0.0 ? (float)elapsed : 0f,
|
||||
liveCenterX,
|
||||
liveCenterY,
|
||||
playerWorldPosition);
|
||||
}
|
||||
|
||||
internal void Tick(
|
||||
double currentTime,
|
||||
float elapsedSeconds,
|
||||
int liveCenterX,
|
||||
int liveCenterY,
|
||||
Vector3? playerWorldPosition)
|
||||
{
|
||||
if (!double.IsFinite(currentTime))
|
||||
{
|
||||
|
|
@ -519,22 +694,50 @@ internal sealed class ProjectileController
|
|||
continue;
|
||||
|
||||
runtime.Body.State = record.FinalPhysicsState;
|
||||
if (IsHidden(record))
|
||||
{
|
||||
if (!HasVisibleCell(record))
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
continue;
|
||||
}
|
||||
else
|
||||
|
||||
if (!runtime.Body.InWorld)
|
||||
{
|
||||
// UpdatePositionInternal skips root physics/PartArray
|
||||
// while Hidden, but update_object still advances its
|
||||
// clock and does not clear Active.
|
||||
runtime.Body.InWorld = true;
|
||||
runtime.Body.LastUpdateTime = currentTime;
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0)
|
||||
Deactivate(runtime.Body);
|
||||
runtime.Body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
|
||||
if (IsHidden(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)
|
||||
{
|
||||
RetailObjectActivityResult hiddenActivity =
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
runtime.Body,
|
||||
_liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
|
||||
is RetailObjectClockDisposition.Advance,
|
||||
record.HasPartArray,
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0,
|
||||
runtime.Body.Position,
|
||||
playerWorldPosition,
|
||||
elapsedSeconds);
|
||||
if (hiddenActivity is RetailObjectActivityResult.Active)
|
||||
record.ObjectClock.Advance(elapsedSeconds);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -548,23 +751,6 @@ internal sealed class ProjectileController
|
|||
// hydrate as an ordinary live object. Restore its retained
|
||||
// shadow/body membership without reactivating projectile
|
||||
// integration or consuming the out-of-world clock gap.
|
||||
if (!HasVisibleCell(record))
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
else if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.InWorld = true;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
// A retained body without MovementManager still remains an
|
||||
// active CPhysicsObj after Missile is cleared. Continue the
|
||||
// same one-sphere update path; PhysicsEngine reads the final
|
||||
|
|
@ -573,74 +759,122 @@ internal sealed class ProjectileController
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid))
|
||||
{
|
||||
if (!HasVisibleCell(record))
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
else
|
||||
Deactivate(runtime.Body); // Frozen retains its cell/shadow.
|
||||
// Animated owners are admitted by LiveEntityAnimationScheduler so
|
||||
// their PartArray, projectile physics, hooks, and manager tail all
|
||||
// consume one shared object quantum in retail order.
|
||||
if (record.AnimationRuntime is not null)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!runtime.Body.InWorld)
|
||||
{
|
||||
runtime.Body.InWorld = true;
|
||||
Activate(runtime.Body, currentTime);
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
|
||||
// CPhysicsObj::update_object 0x00515D4D-0x00515DB2 keeps
|
||||
// rendered objects active only within 96 world units of the
|
||||
// player. Objects with no PartArray are exempt.
|
||||
if (runtime.HasPartArray && playerWorldPosition is { } playerPosition)
|
||||
{
|
||||
float playerDistance = Vector3.Distance(runtime.Body.Position, playerPosition);
|
||||
if (!float.IsFinite(playerDistance) || playerDistance > 96f)
|
||||
{
|
||||
Deactivate(runtime.Body);
|
||||
continue;
|
||||
}
|
||||
Activate(runtime.Body, currentTime);
|
||||
}
|
||||
|
||||
bool isParented = record.Snapshot.ParentGuid is not null
|
||||
|| record.Snapshot.Physics?.Parent is not null;
|
||||
ProjectileAdvanceResult result = _stepper.Advance(
|
||||
RetailObjectActivityResult activity = RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
runtime.Body,
|
||||
currentTime,
|
||||
record.FullCellId,
|
||||
_liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
|
||||
is RetailObjectClockDisposition.Advance,
|
||||
record.HasPartArray,
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0,
|
||||
runtime.Body.Position,
|
||||
playerWorldPosition,
|
||||
elapsedSeconds);
|
||||
if (activity is not RetailObjectActivityResult.Active)
|
||||
continue;
|
||||
|
||||
RetailObjectQuantumBatch batch = record.ObjectClock.Advance(elapsedSeconds);
|
||||
for (int qi = 0; qi < batch.Count; qi++)
|
||||
{
|
||||
if (!AdvanceQuantum(
|
||||
record,
|
||||
batch.GetQuantum(qi),
|
||||
liveCenterX,
|
||||
liveCenterY))
|
||||
break;
|
||||
if (record.RemoteMotionRuntime is RemoteMotion remote)
|
||||
{
|
||||
RetailObjectManagerTail.Run(
|
||||
remote.Host?.TargetManager,
|
||||
remote.Movement,
|
||||
partArray: null,
|
||||
remote.Host?.PositionManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
isParented);
|
||||
designatedTargetId: 0u);
|
||||
if (!result.Simulated || !IsCurrentQuantumStep(step))
|
||||
return false;
|
||||
|
||||
// An owner can be deleted/replaced by a callback reached from a
|
||||
// future collision sink. Never commit a completed step into a new
|
||||
// incarnation that reused the same server GUID.
|
||||
if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime currentRuntime)
|
||||
|| !ReferenceEquals(runtime, currentRuntime)
|
||||
|| !ReferenceEquals(record, current)
|
||||
|| !result.Simulated)
|
||||
continue;
|
||||
|
||||
uint priorCellId = record.FullCellId;
|
||||
uint resolvedCellId = result.CellId != 0
|
||||
? result.CellId
|
||||
: priorCellId;
|
||||
|
||||
entity.SetPosition(runtime.Body.Position);
|
||||
entity.Rotation = runtime.Body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != priorCellId)
|
||||
_liveEntities.RebucketLiveEntity(record.ServerGuid, resolvedCellId);
|
||||
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,
|
||||
|
|
@ -649,14 +883,90 @@ internal sealed class ProjectileController
|
|||
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))
|
||||
entity.SetPosition(runtime.Body.Position);
|
||||
entity.Rotation = runtime.Body.Orientation;
|
||||
entity.ParentCellId = resolvedCellId;
|
||||
if (resolvedCellId != priorCellId
|
||||
&& !_liveEntities.RebucketLiveEntity(current.ServerGuid, resolvedCellId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A legitimate destination can now be pending and therefore absent
|
||||
// from the spatial-projectile index. Revalidate identity/mutation,
|
||||
// not visibility, after the canonical cell commit.
|
||||
if (!IsCurrentQuantumIdentity(step))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasVisibleCell(current))
|
||||
{
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
runtime.Body.Position,
|
||||
runtime.Body.Orientation,
|
||||
current.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
if (!IsCurrentQuantumIdentity(step))
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
return IsCurrentQuantumIdentity(step);
|
||||
}
|
||||
|
||||
private bool IsCurrentQuantumStep(in QuantumStep step) =>
|
||||
IsCurrentQuantumIdentity(step)
|
||||
&& _liveEntities.IsCurrentSpatialProjectile(step.Record, step.Runtime);
|
||||
|
||||
private bool IsCurrentQuantumIdentity(in QuantumStep step) =>
|
||||
TryGetCurrent(
|
||||
step.Record.ServerGuid,
|
||||
out LiveEntityRecord current,
|
||||
out Runtime runtime)
|
||||
&& ReferenceEquals(current, step.Record)
|
||||
&& ReferenceEquals(runtime, step.Runtime)
|
||||
&& ReferenceEquals(current.WorldEntity, step.Entity)
|
||||
&& runtime.PredictionAuthorityVersion
|
||||
== step.PredictionAuthorityVersion;
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (record.ProjectileRuntime is not Runtime runtime
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, record)
|
||||
|| !ReferenceEquals(current.ProjectileRuntime, runtime))
|
||||
{
|
||||
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,
|
||||
|
|
@ -666,23 +976,6 @@ internal sealed class ProjectileController
|
|||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (visible
|
||||
|| record.ProjectileRuntime is not Runtime runtime
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, record)
|
||||
|| !ReferenceEquals(current.ProjectileRuntime, runtime))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
|
||||
/// 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
|
||||
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
|
||||
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||
///
|
||||
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
|
||||
|
|
@ -68,33 +66,87 @@ internal sealed class RemotePhysicsUpdater
|
|||
AcDream.App.World.LiveEntityRuntime liveEntities,
|
||||
uint localPlayerServerGuid,
|
||||
float dt,
|
||||
System.Action<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(publishRootPose);
|
||||
|
||||
System.Numerics.Vector3? playerPosition = null;
|
||||
if (localPlayerServerGuid != 0
|
||||
&& liveEntities.TryGetWorldEntity(
|
||||
localPlayerServerGuid,
|
||||
out AcDream.Core.World.WorldEntity playerEntity))
|
||||
{
|
||||
playerPosition = playerEntity.Position;
|
||||
}
|
||||
|
||||
liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot);
|
||||
foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot)
|
||||
{
|
||||
if (record.ServerGuid == localPlayerServerGuid
|
||||
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|
||||
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|
||||
|| record.RemoteMotionRuntime is not RemoteMotion remote
|
||||
|| !liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
continue;
|
||||
|
||||
System.Action? handlePartArray = record.AnimationRuntime is AnimatedEntity
|
||||
{ Sequencer: { } sequencer }
|
||||
? sequencer.Manager.UseTime
|
||||
: null;
|
||||
TickHidden(remote, entity, dt, handlePartArray);
|
||||
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity;
|
||||
AcDream.Core.Physics.RetailObjectActivityResult activity =
|
||||
AcDream.Core.Physics.RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
remote.Body,
|
||||
liveEntities.GetRootObjectClockDisposition(record.ServerGuid)
|
||||
is AcDream.Core.Physics.RetailObjectClockDisposition.Advance,
|
||||
hasPartArray: record.HasPartArray,
|
||||
isStatic: (record.FinalPhysicsState
|
||||
& AcDream.Core.Physics.PhysicsStateFlags.Static) != 0,
|
||||
entity.Position,
|
||||
playerPosition,
|
||||
dt);
|
||||
if (activity is not AcDream.Core.Physics.RetailObjectActivityResult.Active)
|
||||
continue;
|
||||
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer = animation?.Sequencer;
|
||||
ulong objectClockEpoch = record.ObjectClockEpoch;
|
||||
AcDream.Core.Physics.RetailObjectQuantumBatch batch =
|
||||
record.ObjectClock.Advance(dt);
|
||||
for (int qi = 0; qi < batch.Count; qi++)
|
||||
{
|
||||
if (!liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
|| !ReferenceEquals(record.WorldEntity, entity)
|
||||
|| record.ObjectClockEpoch != objectClockEpoch)
|
||||
break;
|
||||
if (!TickHidden(
|
||||
remote,
|
||||
entity,
|
||||
batch.GetQuantum(qi),
|
||||
sequencer?.Manager,
|
||||
processAnimationHooks,
|
||||
sequencer,
|
||||
liveEntities,
|
||||
record,
|
||||
objectClockEpoch))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
// PositionManager callbacks can rebucket, delete, or replace this
|
||||
// GUID. Publish only while the captured record/runtime pair still
|
||||
// owns a loaded spatial projection.
|
||||
if (liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
&& ReferenceEquals(record.WorldEntity, entity))
|
||||
if (batch.Count > 0
|
||||
&& liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|
||||
&& ReferenceEquals(record.WorldEntity, entity)
|
||||
&& record.ObjectClockEpoch == objectClockEpoch)
|
||||
{
|
||||
// Hidden suppresses CPartArray::Update, but HandleEnterWorld
|
||||
// may already have replaced the current sequence pose. Tell
|
||||
// the presentation pass to sample that retained pose once for
|
||||
// this admitted object quantum rather than rebuilding it on
|
||||
// every render frame.
|
||||
markPartPoseDirty?.Invoke(record.ServerGuid);
|
||||
publishRootPose(entity);
|
||||
}
|
||||
}
|
||||
|
|
@ -106,28 +158,75 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// body. <c>serverGuid</c> + the entity id derive from
|
||||
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
|
||||
/// are passed per-call (they change on streaming recentre — never snapshot
|
||||
/// them in the constructor). <paramref name="rootMotionLocalDelta"/> is
|
||||
/// the complete local displacement produced by this frame's preceding
|
||||
/// them in the constructor). <paramref name="rootMotionLocalFrame"/> is
|
||||
/// the complete local Frame produced by this frame's preceding
|
||||
/// <c>CSequence::update</c>, matching retail's
|
||||
/// <c>CPartArray::Update → PositionManager::adjust_offset</c> order.
|
||||
/// </summary>
|
||||
public void Tick(
|
||||
public bool Tick(
|
||||
RemoteMotion rm,
|
||||
AnimatedEntity ae,
|
||||
float dt,
|
||||
System.Numerics.Vector3 rootMotionLocalDelta,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
int liveCenterY,
|
||||
System.Action<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
|
||||
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
|
||||
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
|
||||
// problem" premise) is GONE — every remote now runs the SAME catch-up +
|
||||
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
|
||||
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
|
||||
// player/remote fork; the only surviving player/NPC split is the omega
|
||||
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop.
|
||||
// player/remote fork; only the stale animation-cycle stop below remains
|
||||
// player/NPC-specific.
|
||||
//
|
||||
// Stop detection stays explicit on packet receipt (UpdateMotion
|
||||
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
|
||||
|
|
@ -143,8 +242,8 @@ internal sealed class RemotePhysicsUpdater
|
|||
// remotes below are explicitly OnWalkable and airborne remotes use
|
||||
// their authoritative velocity/gravity arc, so this is the same
|
||||
// branch expressed through our retained runtime state.
|
||||
System.Numerics.Vector3 scaledRootMotionLocalDelta = !rm.Airborne
|
||||
? rootMotionLocalDelta * ae.Scale
|
||||
System.Numerics.Vector3 scaledRootMotionLocalOrigin = !rm.Airborne
|
||||
? rootMotionLocalFrame.Origin * objectScale
|
||||
: System.Numerics.Vector3.Zero;
|
||||
|
||||
// Step 1: re-apply current motion commands → body.Velocity.
|
||||
|
|
@ -199,13 +298,16 @@ internal sealed class RemotePhysicsUpdater
|
|||
{
|
||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rm.HasServerVelocity = false;
|
||||
if (animationForVelocityCycle is not null)
|
||||
{
|
||||
_applyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
ae,
|
||||
animationForVelocityCycle,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
|
||||
// MovementManager::UseTime per tick, UpdateObjectInternal call
|
||||
|
|
@ -223,58 +325,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
// Step 2: integrate rotation manually per tick. We can't
|
||||
// rely on PhysicsBody.update_object here — its MinQuantum
|
||||
// gate (1/30 s) causes it to SKIP integration when our
|
||||
// 60fps render dt (~0.016s) is below the quantum, meaning
|
||||
// rotation never advances. Measured snap per UP was ~129°
|
||||
// = the full expected 1s × 2.24 rad/s, confirming zero
|
||||
// between-tick rotation.
|
||||
//
|
||||
// Manual integration matches retail's FUN_005256b0
|
||||
// apply_physics (Orientation *= quat(ω × dt)). Use
|
||||
// ObservedOmega derived from server UP rotation deltas so
|
||||
// the rate exactly matches server physics — hard-snap on
|
||||
// next UP becomes invisible by construction.
|
||||
// #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback +
|
||||
// world-frame (pre-multiply, Concatenate) application inherited from the
|
||||
// former Path A — a circling player sends RunForward+TurnLeft on ONE UM
|
||||
// whose RunForward cycle synthesises zero omega, so ObservedOmega (from
|
||||
// the wire TurnCommand) must carry the turn or the body would not rotate
|
||||
// between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies
|
||||
// keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a
|
||||
// seqOmega fallback would change NPC turning (handoff 4.1), so the split
|
||||
// is preserved. For an upright body + a yaw (world-Z) omega the two
|
||||
// multiplication orders commute, so this fork is faithful, not cosmetic.
|
||||
// calc_acceleration zeroes Body.Omega for grounded bodies before
|
||||
// UpdatePhysicsInternal; the explicit zero here covers the airborne case
|
||||
// (a wire-set Body.Omega would otherwise double-integrate on top of the
|
||||
// manual rotation).
|
||||
rm.Body.Omega = System.Numerics.Vector3.Zero;
|
||||
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
||||
{
|
||||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Vector3 omegaToApply =
|
||||
rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega;
|
||||
if (omegaToApply.LengthSquared() > 1e-9f)
|
||||
{
|
||||
float angleDelta = omegaToApply.Length() * (float)dt;
|
||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||||
}
|
||||
}
|
||||
else if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
||||
{
|
||||
float omegaMag = rm.ObservedOmega.Length();
|
||||
var axis = rm.ObservedOmega / omegaMag;
|
||||
float angle = omegaMag * dt;
|
||||
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||||
}
|
||||
// Step 2: CSequence's complete Frame carries motion-table omega through
|
||||
// the same compose as root translation. PhysicsBody.Omega remains
|
||||
// reserved for the object's physical angular velocity and is
|
||||
// integrated once by UpdatePhysicsInternal below.
|
||||
|
||||
// Step 3: integrate physics — retail FUN_005111D0
|
||||
// UpdatePhysicsInternal. Pure Euler:
|
||||
|
|
@ -289,11 +343,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
// from the UP hard-snap, producing a visible teleport-stride
|
||||
// on slopes (the "staircase" the user reported).
|
||||
//
|
||||
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
|
||||
// directly for the same reason. Remote motion mirrors that.
|
||||
// Omega is already integrated manually above, so we zero it
|
||||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||||
// double-integrating.
|
||||
// PlayerMovementController calls UpdatePhysicsInternal directly
|
||||
// for the same reason. Remote motion mirrors that. CSequence's
|
||||
// authored orientation has already entered the shared delta Frame;
|
||||
// Body.Omega remains the separate physical angular-velocity source.
|
||||
var preIntegratePos = rm.Body.Position;
|
||||
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
|
||||
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
|
||||
|
|
@ -312,54 +365,73 @@ internal sealed class RemotePhysicsUpdater
|
|||
// adds no translation on top of the catch-up — no double-move.
|
||||
if (rm.Host is { } npcHost)
|
||||
{
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
pmDelta.Origin = scaledRootMotionLocalOrigin;
|
||||
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
rootMotionLocalDelta: scaledRootMotionLocalDelta,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation, offsetNpc),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
}
|
||||
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
|
||||
? _physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X,
|
||||
rm.Body.Position.Y)
|
||||
: null;
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
pmDelta,
|
||||
rm.Interp,
|
||||
maxSpeedNpc,
|
||||
pmDelta,
|
||||
terrainNormalNpc,
|
||||
inContact: rm.Body.InContact);
|
||||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
else if (!rm.Airborne)
|
||||
else
|
||||
{
|
||||
// No PositionManager host yet (pre-binding): apply the catch-up
|
||||
// directly, matching Path A's fallback (:10202).
|
||||
// Airborne suppresses only CPartArray Origin. Retail keeps the
|
||||
// complete root orientation live across the OnWalkable scale
|
||||
// gate in UpdatePositionInternal (0x00512C30).
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
pmDelta.Origin = scaledRootMotionLocalOrigin;
|
||||
pmDelta.Orientation = rootMotionLocalFrame.Orientation;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
rm.Body.Position += rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
rootMotionLocalDelta: scaledRootMotionLocalDelta,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
System.Numerics.Vector3? terrainNormalNpc = !rm.Airborne
|
||||
? _physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X,
|
||||
rm.Body.Position.Y)
|
||||
: null;
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
pmDelta,
|
||||
rm.Interp,
|
||||
maxSpeedNpc,
|
||||
pmDelta,
|
||||
terrainNormalNpc,
|
||||
inContact: rm.Body.InContact);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
rm.Body.calc_acceleration();
|
||||
rm.Body.UpdatePhysicsInternal(dt);
|
||||
if (sequencer is { } hookSequencer)
|
||||
processAnimationHooks?.Invoke(entity.Id, hookSequencer);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var postIntegratePos = rm.Body.Position;
|
||||
uint committedCellId = rm.CellId;
|
||||
|
||||
// Step 4: collision sweep — retail FUN_00514B90 →
|
||||
// FUN_005148A0 → Transition::FindTransitionalPosition.
|
||||
|
|
@ -399,7 +471,7 @@ internal sealed class RemotePhysicsUpdater
|
|||
// Fallback to the human capsule for a shapeless / unresolvable
|
||||
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
|
||||
// degenerate the sweep.
|
||||
var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
|
||||
var (deR, deH) = _getSetupCylinder(serverGuid, entity);
|
||||
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
|
||||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||
preIntegratePos, postIntegratePos, rm.CellId,
|
||||
|
|
@ -442,11 +514,11 @@ internal sealed class RemotePhysicsUpdater
|
|||
// the remote's own cylinder and produces ~1m of
|
||||
// horizontal drift on the first jump frame
|
||||
// (validated by [SWEEP-OBJ] traces).
|
||||
movingEntityId: ae.Entity.Id);
|
||||
movingEntityId: entity.Id);
|
||||
|
||||
rm.Body.Position = resolveResult.Position;
|
||||
if (resolveResult.CellId != 0)
|
||||
rm.CellId = resolveResult.CellId;
|
||||
committedCellId = resolveResult.CellId;
|
||||
|
||||
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
|
||||
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
|
||||
|
|
@ -460,9 +532,9 @@ internal sealed class RemotePhysicsUpdater
|
|||
// player would collide with a shadow offset from where the monster
|
||||
// renders (the reverted attempt's "stuck on an invisible monster").
|
||||
// Syncing the shadow to the resolved body every tick makes the
|
||||
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
|
||||
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm since the last shadow registration. This is
|
||||
// de-overlap PERSIST and keeps collision == render. Re-flood is
|
||||
// POSE/CELL-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm, rotated, or entered another cell. This is
|
||||
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
|
||||
// players too — every remote's shadow (player + NPC) is written ONLY
|
||||
// by this loop + the UP-branch tail, both to the resolved body, so a
|
||||
|
|
@ -473,12 +545,6 @@ internal sealed class RemotePhysicsUpdater
|
|||
// to actually-moving remotes — the perf risk the review flagged for
|
||||
// a packed town. (In-place shadow-move + cell-relink-on-change is a
|
||||
// further optimization if profiling still shows churn.)
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
|
||||
{
|
||||
SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
|
||||
}
|
||||
|
||||
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||
// remote objects included; a VectorUpdate-launched jump arc
|
||||
|
|
@ -578,21 +644,69 @@ internal sealed class RemotePhysicsUpdater
|
|||
// airborne UseTime contact gate; without it a
|
||||
// chasing NPC that lands stalls until ACE's
|
||||
// ~1 Hz re-emit.
|
||||
ulong landingStateAuthorityVersion =
|
||||
ownerRecord?.StateAuthorityVersion ?? 0UL;
|
||||
rm.Movement.HitGround();
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// DR bookkeeping only (partner of the jump-start
|
||||
// `State |= Gravity`): stops the per-tick gravity
|
||||
// integration for the grounded body.
|
||||
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
if (ownerRecord is null
|
||||
|| ownerRecord.StateAuthorityVersion
|
||||
== landingStateAuthorityVersion)
|
||||
{
|
||||
rm.Body.State &=
|
||||
~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||||
if (rm.CellId != 0)
|
||||
ae.Entity.ParentCellId = rm.CellId;
|
||||
ae.Entity.Rotation = rm.Body.Orientation;
|
||||
// SetPositionInternal commits the resolved body/contact result and
|
||||
// root frame as one object before changing cell membership. The
|
||||
// canonical CellId writer can synchronously rebucket loaded to
|
||||
// pending, delete, or replace this GUID, so it is the transaction's
|
||||
// final callback boundary before shadow publication.
|
||||
entity.SetPosition(rm.Body.Position);
|
||||
entity.ParentCellId = committedCellId;
|
||||
entity.Rotation = rm.Body.Orientation;
|
||||
bool cellChanged = committedCellId != 0
|
||||
&& committedCellId != rm.CellId;
|
||||
if (cellChanged)
|
||||
rm.CellId = committedCellId;
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// #184: shadow follows the resolved body, but only after the
|
||||
// canonical rebucket proves this exact owner is still spatially
|
||||
// resident. A pending destination keeps its retained registration
|
||||
// suspended; a callback-created replacement owns another local ID.
|
||||
if (ShouldSynchronizeShadow(
|
||||
cellChanged,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
rm.LastShadowSyncPos,
|
||||
rm.LastShadowSyncOrientation))
|
||||
{
|
||||
SyncRemoteShadowToBody(entity.Id, rm, liveCenterX, liveCenterY);
|
||||
}
|
||||
}
|
||||
|
||||
// R5-V3 (#171): retail UpdateObjectInternal tail —
|
||||
|
|
@ -602,21 +716,17 @@ internal sealed class RemotePhysicsUpdater
|
|||
// sticky 1 s lease watchdog (StickyManager::UseTime
|
||||
// 0x00555610 — a stick not re-issued by a fresh server arm
|
||||
// within 1 s tears itself down). No-op while nothing is stuck.
|
||||
System.Action? handleTargeting = rm.Host is { } targetHost
|
||||
? targetHost.HandleTargetting
|
||||
: null;
|
||||
System.Action? partArrayHandleMovement = ae.Sequencer is { } sequencer
|
||||
? sequencer.Manager.UseTime
|
||||
: null;
|
||||
System.Action? positionUseTime = rm.Host is { } positionHost
|
||||
? positionHost.PositionManager.UseTime
|
||||
: null;
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
checkDetection: null,
|
||||
handleTargeting,
|
||||
movementUseTime: rm.Movement.UseTime,
|
||||
partArrayHandleMovement,
|
||||
positionUseTime);
|
||||
rm.Host?.TargetManager,
|
||||
rm.Movement,
|
||||
sequencer?.Manager,
|
||||
rm.Host?.PositionManager);
|
||||
return IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -628,37 +738,69 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// position managers still consume time. Physics-script and particle owners
|
||||
/// tick later in the shared frame pipeline.
|
||||
/// </summary>
|
||||
public void TickHidden(
|
||||
public bool TickHidden(
|
||||
RemoteMotion rm,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
float dt,
|
||||
System.Action? partArrayHandleMovement = null)
|
||||
AcDream.Core.Physics.Motion.MotionTableManager?
|
||||
partArrayHandleMovement = null,
|
||||
System.Action<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(entity);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 preComposePosition = rm.Body.Position;
|
||||
|
||||
// The part-array contribution is the identity frame while Hidden.
|
||||
// Interpolation is the first PositionManager stage in retail; acdream
|
||||
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint.
|
||||
System.Numerics.Vector3 interpolationOffset = rm.Position.ComputeOffset(
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame positionDelta =
|
||||
rm.PositionManagerDeltaScratch;
|
||||
positionDelta.Reset();
|
||||
rm.Position.ComposeOffset(
|
||||
dt,
|
||||
rm.Body.Position,
|
||||
System.Numerics.Vector3.Zero,
|
||||
rm.Body.Orientation,
|
||||
positionDelta,
|
||||
rm.Interp,
|
||||
rm.Motion.GetMaxSpeed());
|
||||
var positionDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation,
|
||||
interpolationOffset),
|
||||
};
|
||||
rm.Motion.GetMaxSpeed(),
|
||||
positionDelta,
|
||||
inContact: rm.Body.InContact);
|
||||
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, positionDelta);
|
||||
|
||||
// Hidden suppresses CPartArray::Update, but process_hooks remains the
|
||||
// final UpdatePositionInternal step. Drain any hook already pending on
|
||||
// the retained sequence before transition/manager time, exactly like
|
||||
// the visible path above.
|
||||
if (sequencer is not null)
|
||||
processAnimationHooks?.Invoke(entity.Id, sequencer);
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 composedPosition = rm.Body.Position;
|
||||
uint committedCellId = rm.CellId;
|
||||
if (rm.CellId != 0
|
||||
&& composedPosition != preComposePosition
|
||||
&& _physicsEngine.LandblockCount > 0)
|
||||
|
|
@ -689,8 +831,8 @@ internal sealed class RemotePhysicsUpdater
|
|||
movingEntityId: entity.Id);
|
||||
rm.Body.Position = resolved.Position;
|
||||
if (resolved.CellId != 0)
|
||||
rm.CellId = resolved.CellId;
|
||||
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
committedCellId = resolved.CellId;
|
||||
if (!AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
rm.Body,
|
||||
resolved.InContact,
|
||||
resolved.OnWalkable,
|
||||
|
|
@ -699,39 +841,71 @@ internal sealed class RemotePhysicsUpdater
|
|||
previousContact,
|
||||
previousOnWalkable,
|
||||
rm.Movement.HitGround,
|
||||
rm.Motion.LeaveGround);
|
||||
rm.Motion.LeaveGround,
|
||||
() => IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rm.Airborne = !rm.Body.OnWalkable;
|
||||
}
|
||||
|
||||
// Hidden suppresses mesh/part updates, not SetPositionInternal. Commit
|
||||
// the resolved root before the canonical cell writer enters the same
|
||||
// re-entrant rebucket boundary as the visible path.
|
||||
entity.SetPosition(rm.Body.Position);
|
||||
if (rm.CellId != 0)
|
||||
entity.ParentCellId = rm.CellId;
|
||||
entity.ParentCellId = committedCellId;
|
||||
entity.Rotation = rm.Body.Orientation;
|
||||
|
||||
System.Action? handleTargeting = rm.Host is { } targetHost
|
||||
? targetHost.HandleTargetting
|
||||
: null;
|
||||
System.Action? positionUseTime = rm.Host is { } positionHost
|
||||
? positionHost.PositionManager.UseTime
|
||||
: null;
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
checkDetection: null,
|
||||
handleTargeting,
|
||||
movementUseTime: rm.Movement.UseTime,
|
||||
partArrayHandleMovement,
|
||||
positionUseTime);
|
||||
if (committedCellId != 0 && committedCellId != rm.CellId)
|
||||
rm.CellId = committedCellId;
|
||||
if (!IsCurrentOwner(
|
||||
ownerRuntime,
|
||||
ownerRecord,
|
||||
rm,
|
||||
entity,
|
||||
ownerClockEpoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AcDream.Core.Physics.RetailObjectManagerTail.Run(
|
||||
rm.Host?.TargetManager,
|
||||
rm.Movement,
|
||||
partArrayHandleMovement,
|
||||
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>
|
||||
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
|
||||
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
|
||||
/// stand-in for retail's <c>Frame::combine</c> in
|
||||
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
|
||||
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
|
||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
|
||||
/// out by the body orientation. An untouched (identity) rotation means "no
|
||||
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
|
||||
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
|
||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining rotates it
|
||||
/// out by the body's current orientation and post-multiplies the complete
|
||||
/// relative orientation. Moved from GameWindow (#184 Slice 2a); the
|
||||
/// DR tick is its only caller.
|
||||
/// </summary>
|
||||
private static void ApplyPositionManagerDelta(
|
||||
|
|
@ -741,10 +915,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
if (delta.Origin != System.Numerics.Vector3.Zero)
|
||||
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
|
||||
if (!delta.Orientation.IsIdentity)
|
||||
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
|
||||
body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
|
||||
body.Position,
|
||||
body.Orientation,
|
||||
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
|
||||
+ delta.GetHeading());
|
||||
body.Orientation * delta.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -756,7 +930,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
|
||||
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
|
||||
/// 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
|
||||
/// UP-branch tail.
|
||||
/// </summary>
|
||||
|
|
@ -774,8 +951,53 @@ internal sealed class RemotePhysicsUpdater
|
|||
liveCenterY,
|
||||
authoritativeCellId ?? rm.CellId);
|
||||
rm.LastShadowSyncPosition = rm.Body.Position;
|
||||
rm.LastShadowSyncOrientation = rm.Body.Orientation;
|
||||
}
|
||||
|
||||
internal static bool ShouldSynchronizeShadowPose(
|
||||
System.Numerics.Vector3 currentPosition,
|
||||
System.Numerics.Quaternion currentOrientation,
|
||||
System.Numerics.Vector3 lastPosition,
|
||||
System.Numerics.Quaternion lastOrientation)
|
||||
{
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
currentPosition,
|
||||
lastPosition) > 1e-4f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float currentLengthSquared = currentOrientation.LengthSquared();
|
||||
float lastLengthSquared = lastOrientation.LengthSquared();
|
||||
if (!float.IsFinite(currentLengthSquared)
|
||||
|| !float.IsFinite(lastLengthSquared)
|
||||
|| currentLengthSquared < 1e-12f
|
||||
|| lastLengthSquared < 1e-12f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float normalizedDot = MathF.Abs(
|
||||
System.Numerics.Quaternion.Dot(
|
||||
currentOrientation,
|
||||
lastOrientation)
|
||||
/ MathF.Sqrt(currentLengthSquared * lastLengthSquared));
|
||||
return !float.IsFinite(normalizedDot) || normalizedDot < 0.99999f;
|
||||
}
|
||||
|
||||
internal static bool ShouldSynchronizeShadow(
|
||||
bool cellChanged,
|
||||
System.Numerics.Vector3 currentPosition,
|
||||
System.Numerics.Quaternion currentOrientation,
|
||||
System.Numerics.Vector3 lastPosition,
|
||||
System.Numerics.Quaternion lastOrientation) =>
|
||||
cellChanged
|
||||
|| ShouldSynchronizeShadowPose(
|
||||
currentPosition,
|
||||
currentOrientation,
|
||||
lastPosition,
|
||||
lastOrientation);
|
||||
|
||||
public void SyncRemoteShadowToBody(
|
||||
uint entityId,
|
||||
AcDream.Core.Physics.PhysicsBody body,
|
||||
|
|
|
|||
|
|
@ -65,9 +65,13 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool ContactResolved,
|
||||
Vector3 Position,
|
||||
uint CellId,
|
||||
Quaternion Orientation);
|
||||
Quaternion Orientation,
|
||||
bool Superseded = false);
|
||||
|
||||
private readonly record struct PendingPlacement(
|
||||
LiveEntityRecord Record,
|
||||
ulong PositionAuthorityVersion,
|
||||
ulong VelocityAuthorityVersion,
|
||||
ILiveEntityRemotePlacementRuntime Remote,
|
||||
PhysicsBody Body,
|
||||
WorldEntity Entity,
|
||||
|
|
@ -95,9 +99,53 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool Airborne,
|
||||
Vector3 LastServerPosition,
|
||||
double LastServerPositionTime,
|
||||
Vector3 LastShadowSyncPosition);
|
||||
Vector3 LastShadowSyncPosition,
|
||||
Quaternion LastShadowSyncOrientation);
|
||||
|
||||
/// <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(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedPositionAuthorityVersion,
|
||||
ulong expectedVelocityAuthorityVersion,
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
WorldEntity entity,
|
||||
Vector3 requestedWorldPosition,
|
||||
|
|
@ -111,8 +159,15 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord)
|
||||
|| !ReferenceEquals(liveRecord, expectedRecord)
|
||||
|| !_liveEntities.IsCurrentPositionAuthority(
|
||||
expectedRecord,
|
||||
expectedPositionAuthorityVersion)
|
||||
|| liveRecord.Generation != generation
|
||||
|| !ReferenceEquals(liveRecord.WorldEntity, entity)
|
||||
|| !ReferenceEquals(liveRecord.RemoteMotionRuntime, remote)
|
||||
|| liveRecord.PhysicsBody is null
|
||||
|| !ReferenceEquals(liveRecord.PhysicsBody, remote.Body))
|
||||
{
|
||||
|
|
@ -124,6 +179,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable;
|
||||
RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody);
|
||||
if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior)
|
||||
&& ReferenceEquals(prior.Record, liveRecord)
|
||||
&& prior.Generation == generation)
|
||||
{
|
||||
wasInContact = prior.WasInContact;
|
||||
|
|
@ -132,6 +188,9 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
}
|
||||
|
||||
PendingPlacement request = new(
|
||||
liveRecord,
|
||||
expectedPositionAuthorityVersion,
|
||||
expectedVelocityAuthorityVersion,
|
||||
remote,
|
||||
liveRecord.PhysicsBody,
|
||||
entity,
|
||||
|
|
@ -146,7 +205,8 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
rollback);
|
||||
if (!destinationProjectionVisible)
|
||||
{
|
||||
ParkPending(request, requestedCellLocalPosition);
|
||||
if (!ParkPending(request, requestedCellLocalPosition))
|
||||
return Superseded(request);
|
||||
return new Result(
|
||||
true,
|
||||
false,
|
||||
|
|
@ -155,10 +215,13 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
requestedOrientation);
|
||||
}
|
||||
ResolveResult placement = Resolve(request);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!placement.Ok)
|
||||
{
|
||||
_pending.Remove(entity.ServerGuid);
|
||||
RollBackDeferredPlacement(request);
|
||||
if (!RollBackDeferredPlacement(request))
|
||||
return Superseded(request);
|
||||
return new Result(
|
||||
false,
|
||||
false,
|
||||
|
|
@ -250,7 +313,9 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
|
||||
private Result CommitResolved(PendingPlacement request, ResolveResult placement)
|
||||
{
|
||||
RemoteTeleportPlacement.Apply(
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!RemoteTeleportPlacement.Apply(
|
||||
request.Remote,
|
||||
request.Body,
|
||||
placement,
|
||||
|
|
@ -258,16 +323,29 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.RequestedOrientation,
|
||||
request.GameTime,
|
||||
request.WasInContact,
|
||||
request.WasOnWalkable);
|
||||
request.WasOnWalkable,
|
||||
() => IsCurrent(request),
|
||||
() => IsVelocityCurrent(request)))
|
||||
{
|
||||
return Superseded(request);
|
||||
}
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (request.Remote.CellId != placement.CellId)
|
||||
request.Remote.CellId = placement.CellId;
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
request.Remote.LastServerPosition = placement.Position;
|
||||
request.Remote.LastServerPositionTime = request.GameTime;
|
||||
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
request.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
request.Entity.SetPosition(request.Body.Position);
|
||||
request.Entity.ParentCellId = placement.CellId;
|
||||
request.Entity.Rotation = request.Body.Orientation;
|
||||
if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record))
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record)
|
||||
&& ReferenceEquals(record, request.Record))
|
||||
{
|
||||
bool deferShadowRestore =
|
||||
record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|
||||
|
|
@ -276,10 +354,15 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Entity.ServerGuid,
|
||||
request.Generation,
|
||||
deferShadowRestore);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
if (!deferShadowRestore)
|
||||
{
|
||||
request.Remote.LastShadowSyncPosition = request.Body.Position;
|
||||
request.Remote.LastShadowSyncOrientation = request.Body.Orientation;
|
||||
_syncResolvedShadow(request.Entity, request.Body, placement.CellId);
|
||||
if (!IsCurrent(request))
|
||||
return Superseded(request);
|
||||
}
|
||||
}
|
||||
return new Result(
|
||||
|
|
@ -290,8 +373,10 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Body.Orientation);
|
||||
}
|
||||
|
||||
private void ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
|
||||
private bool ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
|
||||
{
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
request.Body.Orientation = request.RequestedOrientation;
|
||||
request.Body.SnapToCell(
|
||||
request.RequestedCellId,
|
||||
|
|
@ -308,13 +393,19 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
request.Remote.Airborne = true;
|
||||
if (request.Remote.CellId != request.RequestedCellId)
|
||||
request.Remote.CellId = request.RequestedCellId;
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
request.Remote.LastServerPosition = request.RequestedWorldPosition;
|
||||
request.Remote.LastServerPositionTime = request.GameTime;
|
||||
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
request.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
request.Entity.SetPosition(request.RequestedWorldPosition);
|
||||
request.Entity.ParentCellId = request.RequestedCellId;
|
||||
request.Entity.Rotation = request.RequestedOrientation;
|
||||
if (!IsCurrent(request))
|
||||
return false;
|
||||
_pending[request.Entity.ServerGuid] = request;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
|
|
@ -325,6 +416,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending))
|
||||
{
|
||||
if (record.WorldEntity is null
|
||||
|| !ReferenceEquals(record, pending.Record)
|
||||
|| !ReferenceEquals(record.WorldEntity, pending.Entity)
|
||||
|| record.Generation != pending.Generation)
|
||||
{
|
||||
|
|
@ -351,7 +443,10 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending = pending with { Remote = currentRemote };
|
||||
_pending[record.ServerGuid] = pending;
|
||||
}
|
||||
if (record.Snapshot.PositionSequence != pending.PositionSequence)
|
||||
if (!_liveEntities.IsCurrentPositionAuthority(
|
||||
pending.Record,
|
||||
pending.PositionAuthorityVersion)
|
||||
|| record.Snapshot.PositionSequence != pending.PositionSequence)
|
||||
{
|
||||
// A newer accepted UpdatePosition rebucketed the projection
|
||||
// before its TryApply call could replace this request. Keep
|
||||
|
|
@ -361,6 +456,8 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
|
||||
_pending.Remove(record.ServerGuid);
|
||||
ResolveResult placement = Resolve(pending);
|
||||
if (!IsCurrent(pending))
|
||||
return;
|
||||
if (!placement.Ok)
|
||||
RollBackDeferredPlacement(pending);
|
||||
else
|
||||
|
|
@ -388,11 +485,14 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
remote.Airborne,
|
||||
remote.LastServerPosition,
|
||||
remote.LastServerPositionTime,
|
||||
remote.LastShadowSyncPosition);
|
||||
remote.LastShadowSyncPosition,
|
||||
remote.LastShadowSyncOrientation);
|
||||
}
|
||||
|
||||
private void RollBackDeferredPlacement(PendingPlacement pending)
|
||||
private bool RollBackDeferredPlacement(PendingPlacement pending)
|
||||
{
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
RollbackPlacement rollback = pending.Rollback;
|
||||
PhysicsBody body = pending.Body;
|
||||
body.Orientation = rollback.Orientation;
|
||||
|
|
@ -412,21 +512,26 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Remote.LastServerPosition = rollback.LastServerPosition;
|
||||
pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime;
|
||||
pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition;
|
||||
pending.Remote.LastShadowSyncOrientation = rollback.LastShadowSyncOrientation;
|
||||
pending.Entity.SetPosition(rollback.Position);
|
||||
pending.Entity.ParentCellId = rollback.CellId;
|
||||
pending.Entity.Rotation = rollback.Orientation;
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
|
||||
if (rollback.CellId == 0)
|
||||
_liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid);
|
||||
else
|
||||
_liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId);
|
||||
|
||||
if (!_liveEntities.TryGetRecord(
|
||||
if (!IsCurrentAuthority(pending)
|
||||
|| !_liveEntities.TryGetRecord(
|
||||
pending.Entity.ServerGuid,
|
||||
out LiveEntityRecord restored)
|
||||
|| !ReferenceEquals(restored, pending.Record)
|
||||
|| restored.Generation != pending.Generation)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rollback.CellId == 0)
|
||||
|
|
@ -435,7 +540,7 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Entity.ServerGuid,
|
||||
pending.Generation,
|
||||
false);
|
||||
return;
|
||||
return IsCurrentAuthority(pending);
|
||||
}
|
||||
|
||||
bool deferShadowRestore =
|
||||
|
|
@ -445,13 +550,42 @@ internal sealed class RemoteTeleportController : IDisposable
|
|||
pending.Entity.ServerGuid,
|
||||
pending.Generation,
|
||||
deferShadowRestore);
|
||||
if (!IsCurrentAuthority(pending))
|
||||
return false;
|
||||
if (deferShadowRestore)
|
||||
return;
|
||||
return true;
|
||||
|
||||
pending.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||
pending.Remote.LastShadowSyncOrientation = Quaternion.Zero;
|
||||
_syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId);
|
||||
return IsCurrentAuthority(pending);
|
||||
}
|
||||
|
||||
private bool IsCurrentAuthority(PendingPlacement request) =>
|
||||
_liveEntities.IsCurrentPositionAuthority(
|
||||
request.Record,
|
||||
request.PositionAuthorityVersion)
|
||||
&& request.Record.Generation == request.Generation
|
||||
&& ReferenceEquals(request.Record.WorldEntity, request.Entity)
|
||||
&& ReferenceEquals(request.Record.PhysicsBody, request.Body);
|
||||
|
||||
private bool IsCurrent(PendingPlacement request) =>
|
||||
IsCurrentAuthority(request)
|
||||
&& ReferenceEquals(request.Record.RemoteMotionRuntime, request.Remote);
|
||||
|
||||
private bool IsVelocityCurrent(PendingPlacement request) =>
|
||||
_liveEntities.IsCurrentVelocityAuthority(
|
||||
request.Record,
|
||||
request.VelocityAuthorityVersion);
|
||||
|
||||
private static Result Superseded(PendingPlacement request) => new(
|
||||
Applied: false,
|
||||
ContactResolved: false,
|
||||
Position: request.Body.Position,
|
||||
CellId: request.Remote.CellId,
|
||||
Orientation: request.Body.Orientation,
|
||||
Superseded: true);
|
||||
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ public static class RemoteTeleportHook
|
|||
{
|
||||
private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu;
|
||||
|
||||
public static void Execute(RemoteTeleportHookActions actions)
|
||||
public static bool Execute(
|
||||
RemoteTeleportHookActions actions,
|
||||
Func<bool>? isCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actions.CancelMoveTo);
|
||||
ArgumentNullException.ThrowIfNull(actions.UnStick);
|
||||
|
|
@ -21,12 +23,26 @@ public static class RemoteTeleportHook
|
|||
ArgumentNullException.ThrowIfNull(actions.NotifyTeleported);
|
||||
ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd);
|
||||
|
||||
bool Current() => isCurrent?.Invoke() ?? true;
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.CancelMoveTo(TeleportCancelContext);
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.UnStick();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.StopInterpolating();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.UnConstrain();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.NotifyTeleported();
|
||||
if (!Current())
|
||||
return false;
|
||||
actions.ReportCollisionEnd();
|
||||
return Current();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ namespace AcDream.App.Physics;
|
|||
/// </summary>
|
||||
internal static class RemoteTeleportPlacement
|
||||
{
|
||||
internal static void Apply(
|
||||
internal static bool Apply(
|
||||
ILiveEntityRemotePlacementRuntime remote,
|
||||
PhysicsBody body,
|
||||
ResolveResult placement,
|
||||
|
|
@ -20,7 +20,9 @@ internal static class RemoteTeleportPlacement
|
|||
Quaternion orientation,
|
||||
double gameTime,
|
||||
bool previousContact,
|
||||
bool previousOnWalkable)
|
||||
bool previousOnWalkable,
|
||||
Func<bool>? isCurrent = null,
|
||||
Func<bool>? isVelocityCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
|
|
@ -55,7 +57,7 @@ internal static class RemoteTeleportPlacement
|
|||
body.ContactPlaneIsWater = false;
|
||||
}
|
||||
|
||||
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
if (!PhysicsObjUpdate.CommitSetPositionTransition(
|
||||
body,
|
||||
placement.InContact,
|
||||
placement.OnWalkable,
|
||||
|
|
@ -64,9 +66,15 @@ internal static class RemoteTeleportPlacement
|
|||
previousContact,
|
||||
previousOnWalkable,
|
||||
remote.HitGround,
|
||||
remote.LeaveGround);
|
||||
remote.LeaveGround,
|
||||
isCurrent,
|
||||
isVelocityCurrent))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (isVelocityCurrent?.Invoke() != false)
|
||||
remote.Airborne = !body.OnWalkable;
|
||||
|
||||
return isCurrent?.Invoke() ?? true;
|
||||
}
|
||||
|
||||
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 IEntityEffectPoseSource _poses;
|
||||
private readonly IEntityEffectPoseLifetimeSource? _poseLifetimes;
|
||||
private readonly List<Entry> _entries = new();
|
||||
|
||||
public AnimationHookFrameQueue(
|
||||
|
|
@ -28,6 +29,7 @@ public sealed class AnimationHookFrameQueue
|
|||
{
|
||||
_router = router ?? throw new ArgumentNullException(nameof(router));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_poseLifetimes = poses as IEntityEffectPoseLifetimeSource;
|
||||
}
|
||||
|
||||
public int Count => _entries.Count;
|
||||
|
|
@ -45,6 +47,12 @@ public sealed class AnimationHookFrameQueue
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
ArgumentNullException.ThrowIfNull(hooks);
|
||||
// Capture incarnation identity before AnimationDone: its semantic
|
||||
// callback can synchronously delete this owner and reuse the local ID.
|
||||
// Reading the version afterwards would mislabel the old hook batch as
|
||||
// belonging to the replacement.
|
||||
ulong ownerLifetimeVersion =
|
||||
_poseLifetimes?.GetPoseOwnerLifetimeVersion(ownerLocalId) ?? 0UL;
|
||||
|
||||
// Retail CPhysicsObj::process_hooks (0x00511550) executes AnimDone
|
||||
// inside UpdatePositionInternal, before the transition and every
|
||||
|
|
@ -55,12 +63,25 @@ public sealed class AnimationHookFrameQueue
|
|||
// authored hook stream after the final part poses are published.
|
||||
for (int i = 0; i < hooks.Count; i++)
|
||||
{
|
||||
// MotionDone can synchronously delete this owner and reuse the
|
||||
// local ID. Do not advance the displaced sequencer for later
|
||||
// AnimDone hooks from the old catch-up batch.
|
||||
if (_poseLifetimes is not null
|
||||
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
|
||||
ownerLocalId) != ownerLifetimeVersion)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (hooks[i] is AnimationDoneHook)
|
||||
sequencer.Manager.AnimationDone(success: true);
|
||||
}
|
||||
|
||||
if (hooks.Count == 0)
|
||||
return;
|
||||
|
||||
_entries.Add(new Entry(
|
||||
ownerLocalId,
|
||||
ownerLifetimeVersion,
|
||||
hooks));
|
||||
}
|
||||
|
||||
|
|
@ -69,19 +90,35 @@ public sealed class AnimationHookFrameQueue
|
|||
for (int i = 0; i < _entries.Count; i++)
|
||||
{
|
||||
Entry entry = _entries[i];
|
||||
bool hasRootPose = _poses.TryGetRootPose(
|
||||
entry.OwnerLocalId,
|
||||
out Matrix4x4 rootWorld);
|
||||
Vector3 worldPosition = hasRootPose
|
||||
? rootWorld.Translation
|
||||
: Vector3.Zero;
|
||||
if (_poseLifetimes is not null
|
||||
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
|
||||
entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (int hi = 0; hi < entry.Hooks.Count; hi++)
|
||||
{
|
||||
// A prior hook sink can tear down and replace this local ID.
|
||||
// Revalidate per hook so the remainder of the old PES/animation
|
||||
// batch can never spill into the replacement incarnation.
|
||||
if (_poseLifetimes is not null
|
||||
&& _poseLifetimes.GetPoseOwnerLifetimeVersion(
|
||||
entry.OwnerLocalId) != entry.OwnerLifetimeVersion)
|
||||
{
|
||||
break;
|
||||
}
|
||||
AnimationHook? hook = entry.Hooks[hi];
|
||||
if (hook is null)
|
||||
continue;
|
||||
if (hasRootPose)
|
||||
_router.OnHook(entry.OwnerLocalId, worldPosition, hook);
|
||||
if (_poses.TryGetRootPose(
|
||||
entry.OwnerLocalId,
|
||||
out Matrix4x4 rootWorld))
|
||||
{
|
||||
_router.OnHook(
|
||||
entry.OwnerLocalId,
|
||||
rootWorld.Translation,
|
||||
hook);
|
||||
}
|
||||
}
|
||||
}
|
||||
_entries.Clear();
|
||||
|
|
@ -91,5 +128,6 @@ public sealed class AnimationHookFrameQueue
|
|||
|
||||
private readonly record struct Entry(
|
||||
uint OwnerLocalId,
|
||||
ulong OwnerLifetimeVersion,
|
||||
IReadOnlyList<AnimationHook> Hooks);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ namespace AcDream.App.Rendering.Vfx;
|
|||
public sealed class EntityEffectPoseRegistry :
|
||||
IEntityEffectPoseSource,
|
||||
IEntityEffectCellSource,
|
||||
IEntityEffectPoseChangeSource
|
||||
IEntityEffectPoseChangeSource,
|
||||
IEntityEffectPoseLifetimeSource
|
||||
{
|
||||
private sealed class PoseRecord
|
||||
{
|
||||
|
|
@ -26,9 +27,11 @@ public sealed class EntityEffectPoseRegistry :
|
|||
public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>();
|
||||
public bool[] PartAvailable = Array.Empty<bool>();
|
||||
public uint CellId;
|
||||
public ulong LifetimeVersion;
|
||||
}
|
||||
|
||||
private readonly Dictionary<uint, PoseRecord> _poses = new();
|
||||
private ulong _nextLifetimeVersion;
|
||||
|
||||
public event Action<uint>? EffectPoseChanged;
|
||||
|
||||
|
|
@ -68,7 +71,7 @@ public sealed class EntityEffectPoseRegistry :
|
|||
bool changed;
|
||||
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
|
||||
{
|
||||
record = new PoseRecord();
|
||||
record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
|
||||
_poses.Add(entity.Id, record);
|
||||
changed = true;
|
||||
}
|
||||
|
|
@ -126,7 +129,7 @@ public sealed class EntityEffectPoseRegistry :
|
|||
bool changed;
|
||||
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
||||
{
|
||||
record = new PoseRecord();
|
||||
record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
|
||||
_poses.Add(localEntityId, record);
|
||||
changed = true;
|
||||
}
|
||||
|
|
@ -176,8 +179,10 @@ public sealed class EntityEffectPoseRegistry :
|
|||
uint[] removedOwners = _poses.Keys.ToArray();
|
||||
_poses.Clear();
|
||||
foreach (uint owner in removedOwners)
|
||||
{
|
||||
EffectPoseChanged?.Invoke(owner);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
|
||||
{
|
||||
|
|
@ -248,6 +253,19 @@ public sealed class EntityEffectPoseRegistry :
|
|||
return false;
|
||||
}
|
||||
|
||||
public ulong GetPoseOwnerLifetimeVersion(uint localEntityId) =>
|
||||
_poses.TryGetValue(localEntityId, out PoseRecord? record)
|
||||
? record.LifetimeVersion
|
||||
: 0UL;
|
||||
|
||||
private ulong NextLifetimeVersion()
|
||||
{
|
||||
_nextLifetimeVersion++;
|
||||
if (_nextLifetimeVersion == 0UL)
|
||||
_nextLifetimeVersion++;
|
||||
return _nextLifetimeVersion;
|
||||
}
|
||||
|
||||
private static bool CopyParts(
|
||||
PoseRecord record,
|
||||
IReadOnlyList<Matrix4x4> partLocal,
|
||||
|
|
@ -277,3 +295,8 @@ public sealed class EntityEffectPoseRegistry :
|
|||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IEntityEffectPoseLifetimeSource
|
||||
{
|
||||
ulong GetPoseOwnerLifetimeVersion(uint localEntityId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ public sealed record ScriptActivationInfo(
|
|||
uint ScriptId,
|
||||
IReadOnlyList<Matrix4x4> PartTransforms,
|
||||
EntityEffectProfile? EffectProfile = null,
|
||||
IReadOnlyList<bool>? PartAvailability = null);
|
||||
IReadOnlyList<bool>? PartAvailability = null,
|
||||
DatReaderWriter.DBObjs.Setup? Setup = null,
|
||||
uint DefaultAnimationId = 0,
|
||||
bool UsesStaticAnimationWorkset = false);
|
||||
|
||||
/// <summary>
|
||||
/// Owns create-time Setup script activation and its symmetric teardown for
|
||||
|
|
@ -25,13 +28,29 @@ public sealed record ScriptActivationInfo(
|
|||
/// </remarks>
|
||||
public sealed class EntityScriptActivator
|
||||
{
|
||||
private sealed class StaticOwnerState
|
||||
{
|
||||
public required WorldEntity Entity;
|
||||
public required ScriptActivationInfo Info;
|
||||
public bool PosePublished;
|
||||
public bool EffectOwnerRegistered;
|
||||
public bool AnimationOwnerRegistered;
|
||||
public bool ScriptStarted;
|
||||
public bool ReleaseStarted;
|
||||
public bool ScriptsStopped;
|
||||
public bool EmittersStopped;
|
||||
public bool PoseRemoved;
|
||||
}
|
||||
|
||||
private readonly PhysicsScriptRunner _scriptRunner;
|
||||
private readonly ParticleHookSink _particleSink;
|
||||
private readonly EntityEffectPoseRegistry _poses;
|
||||
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
|
||||
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
|
||||
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(
|
||||
PhysicsScriptRunner scriptRunner,
|
||||
|
|
@ -39,7 +58,9 @@ public sealed class EntityScriptActivator
|
|||
EntityEffectPoseRegistry poses,
|
||||
Func<WorldEntity, ScriptActivationInfo?> resolver,
|
||||
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));
|
||||
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
|
||||
|
|
@ -47,6 +68,8 @@ public sealed class EntityScriptActivator
|
|||
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
|
||||
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
|
||||
_registerDatStaticAnimationOwner = registerDatStaticAnimationOwner;
|
||||
_unregisterDatStaticAnimationOwner = unregisterDatStaticAnimationOwner;
|
||||
}
|
||||
|
||||
public void OnCreate(WorldEntity entity)
|
||||
|
|
@ -57,33 +80,93 @@ public sealed class EntityScriptActivator
|
|||
return;
|
||||
|
||||
if (entity.ServerGuid == 0
|
||||
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing))
|
||||
&& _staticOwners.TryGetValue(key, out StaticOwnerState? retained))
|
||||
{
|
||||
if (!ReferenceEquals(retained.Entity, entity))
|
||||
{
|
||||
if (ReferenceEquals(existing, entity))
|
||||
return;
|
||||
throw new InvalidOperationException(
|
||||
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
|
||||
}
|
||||
if (retained.ReleaseStarted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Dat-static WorldEntity id 0x{key:X8} cannot reactivate while teardown is pending.");
|
||||
}
|
||||
|
||||
ActivateStaticOwner(retained);
|
||||
return;
|
||||
}
|
||||
|
||||
ScriptActivationInfo? info = _resolver(entity);
|
||||
if (info is null)
|
||||
return;
|
||||
|
||||
// Particle::Init (0x0051C930) samples the current root/part frames.
|
||||
// Publish before the Setup default script can execute; effects never
|
||||
// fall back to the camera or a cached spawn anchor.
|
||||
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
|
||||
|
||||
if (entity.ServerGuid == 0)
|
||||
_activeStaticOwners.Add(key, entity);
|
||||
|
||||
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
|
||||
_registerDatStaticEffectOwner?.Invoke(key, entity, profile);
|
||||
{
|
||||
var state = new StaticOwnerState
|
||||
{
|
||||
Entity = entity,
|
||||
Info = info,
|
||||
};
|
||||
_staticOwners.Add(key, state);
|
||||
ActivateStaticOwner(state);
|
||||
return;
|
||||
}
|
||||
|
||||
// Live registration is an atomic logical-lifetime edge owned by
|
||||
// LiveEntityRuntime and is never retried at this activator in isolation.
|
||||
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
|
||||
if (info.UsesStaticAnimationWorkset
|
||||
&& info.DefaultAnimationId != 0)
|
||||
{
|
||||
_registerDatStaticAnimationOwner?.Invoke(entity, info);
|
||||
}
|
||||
if (info.ScriptId != 0)
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
}
|
||||
|
||||
private void ActivateStaticOwner(StaticOwnerState state)
|
||||
{
|
||||
WorldEntity entity = state.Entity;
|
||||
ScriptActivationInfo info = state.Info;
|
||||
uint key = entity.Id;
|
||||
|
||||
// Particle::Init (0x0051C930) samples the current root/part frames.
|
||||
// Commit each acquisition only after its callback returns. A later
|
||||
// failure can then retry without replaying completed create-time work.
|
||||
if (!state.PosePublished)
|
||||
{
|
||||
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
|
||||
state.PosePublished = true;
|
||||
}
|
||||
|
||||
if (!state.EffectOwnerRegistered
|
||||
&& info.EffectProfile is { } profile
|
||||
&& _registerDatStaticEffectOwner is not null)
|
||||
{
|
||||
_registerDatStaticEffectOwner(key, entity, profile);
|
||||
state.EffectOwnerRegistered = true;
|
||||
}
|
||||
|
||||
// TS-51: script-only statics still use the shared script runner. The
|
||||
// dedicated static animation scheduler owns only real DefaultAnimation
|
||||
// PartArray work, so script-only owners incur no empty frame scan.
|
||||
if (!state.AnimationOwnerRegistered
|
||||
&& info.UsesStaticAnimationWorkset
|
||||
&& info.DefaultAnimationId != 0
|
||||
&& _registerDatStaticAnimationOwner is not null)
|
||||
{
|
||||
_registerDatStaticAnimationOwner(entity, info);
|
||||
state.AnimationOwnerRegistered = true;
|
||||
}
|
||||
|
||||
if (!state.ScriptStarted && info.ScriptId != 0)
|
||||
{
|
||||
_scriptRunner.Play(info.ScriptId, key, entity.Position);
|
||||
state.ScriptStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnRemove(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
|
@ -91,17 +174,48 @@ public sealed class EntityScriptActivator
|
|||
if (key == 0)
|
||||
return;
|
||||
|
||||
if (entity.ServerGuid == 0
|
||||
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|
||||
|| !ReferenceEquals(existing, entity)))
|
||||
if (entity.ServerGuid == 0)
|
||||
{
|
||||
if (!_staticOwners.TryGetValue(key, out StaticOwnerState? state)
|
||||
|| !ReferenceEquals(state.Entity, entity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
state.ReleaseStarted = true;
|
||||
if (!state.ScriptsStopped)
|
||||
{
|
||||
_scriptRunner.StopAllForEntity(key);
|
||||
state.ScriptsStopped = true;
|
||||
}
|
||||
if (!state.EmittersStopped)
|
||||
{
|
||||
_particleSink.StopAllForEntity(key, fadeOut: false);
|
||||
state.EmittersStopped = true;
|
||||
}
|
||||
if (!state.PoseRemoved)
|
||||
{
|
||||
_poses.Remove(key);
|
||||
state.PoseRemoved = true;
|
||||
}
|
||||
if (state.AnimationOwnerRegistered)
|
||||
{
|
||||
_unregisterDatStaticAnimationOwner?.Invoke(key);
|
||||
state.AnimationOwnerRegistered = false;
|
||||
}
|
||||
if (state.EffectOwnerRegistered)
|
||||
{
|
||||
_unregisterDatStaticEffectOwner?.Invoke(key);
|
||||
state.EffectOwnerRegistered = false;
|
||||
}
|
||||
|
||||
_staticOwners.Remove(key);
|
||||
return;
|
||||
}
|
||||
|
||||
_unregisterDatStaticAnimationOwner?.Invoke(key);
|
||||
_scriptRunner.StopAllForEntity(key);
|
||||
_particleSink.StopAllForEntity(key, fadeOut: false);
|
||||
_poses.Remove(key);
|
||||
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
|
||||
_unregisterDatStaticEffectOwner?.Invoke(key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
|
||||
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
|
||||
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
|
||||
private readonly HashSet<LiveEntityRecord> _drainingRecords = new();
|
||||
private bool _disposed;
|
||||
|
||||
public LiveEntityPresentationController(
|
||||
|
|
@ -69,8 +70,20 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
}
|
||||
|
||||
_readyGenerationByGuid[serverGuid] = record.Generation;
|
||||
ApplyPendingTransitions(record);
|
||||
return true;
|
||||
if (!ApplyPendingTransitions(record)
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| !IsCurrent(record, entity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// An object can materialize into a pending landblock before collision
|
||||
// registration and before this ready barrier opens. Its initial false
|
||||
// visibility edge therefore had nothing to suspend. Reconcile here,
|
||||
// after every create-time owner exists, so the retained registration
|
||||
// cannot remain active offscreen and hydration has a restore marker.
|
||||
SuspendOrdinaryShadowOutsideProjection(record, entity);
|
||||
return IsCurrent(record, entity);
|
||||
}
|
||||
|
||||
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
|
||||
|
|
@ -83,8 +96,7 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
return false;
|
||||
}
|
||||
|
||||
ApplyPendingTransitions(record);
|
||||
return true;
|
||||
return ApplyPendingTransitions(record);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -188,6 +200,7 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
{
|
||||
_activePlacementGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
_drainingRecords.Remove(record);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
|
|
@ -195,6 +208,7 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
_readyGenerationByGuid.Clear();
|
||||
_suspendedShadowGenerationByGuid.Clear();
|
||||
_activePlacementGenerationByGuid.Clear();
|
||||
_drainingRecords.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -206,12 +220,24 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
Clear();
|
||||
}
|
||||
|
||||
private void ApplyPendingTransitions(LiveEntityRecord record)
|
||||
private bool ApplyPendingTransitions(LiveEntityRecord record)
|
||||
{
|
||||
// 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
|
||||
{
|
||||
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
|
||||
{
|
||||
if (record.WorldEntity is not { } entity)
|
||||
return;
|
||||
if (record.WorldEntity is not { } entity
|
||||
|| !IsCurrent(record, entity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// set_state writes the final state before any PartArray/cell side
|
||||
// effect. Retained shadow registrations must see those same bits
|
||||
|
|
@ -222,7 +248,11 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
{
|
||||
case RetailHiddenTransition.BecameHidden:
|
||||
_playTyped(entity.Id, HiddenScriptType, 1f);
|
||||
if (!IsCurrent(record, entity))
|
||||
return false;
|
||||
_setDirectChildrenNoDraw(record.ServerGuid, true);
|
||||
if (!IsCurrent(record, entity))
|
||||
return false;
|
||||
_shadows.Suspend(entity.Id);
|
||||
if (!IsPlacementActive(record))
|
||||
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
|
||||
|
|
@ -233,20 +263,41 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
// 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 (!IsPlacementActive(record) && RestoreShadow(record, entity))
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity)
|
||||
|
|
@ -273,9 +324,25 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
|
||||
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
if (!visible
|
||||
|| record.WorldEntity is not { } entity
|
||||
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|
||||
if (record.WorldEntity is not { } entity
|
||||
|| !IsCurrent(record, entity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// exit_world removes an ordinary object's collision rows on the same
|
||||
// projection edge that suspends its object clock. Keep the retained
|
||||
// registration so a stationary object can be restored immediately on
|
||||
// hydration; it may have no later movement quantum to repair itself.
|
||||
// Projectiles and active authoritative placements own their matching
|
||||
// suspend/restore transaction in their dedicated controllers.
|
||||
if (!visible)
|
||||
{
|
||||
SuspendOrdinaryShadowOutsideProjection(record, entity);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|
||||
|| IsPlacementActive(record)
|
||||
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|
||||
|| readyGeneration != record.Generation
|
||||
|
|
@ -287,10 +354,40 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
if (RestoreShadow(record, entity))
|
||||
bool restored = RestoreShadow(record, entity);
|
||||
if (!IsCurrent(record, entity))
|
||||
return;
|
||||
if (restored)
|
||||
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
|
||||
private void SuspendOrdinaryShadowOutsideProjection(
|
||||
LiveEntityRecord record,
|
||||
AcDream.Core.World.WorldEntity entity)
|
||||
{
|
||||
if (record.IsSpatiallyVisible
|
||||
|| record.ProjectileRuntime is not null
|
||||
|| IsPlacementActive(record)
|
||||
|| !_readyGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
out ushort readyGeneration)
|
||||
|| readyGeneration != record.Generation
|
||||
|| !_shadows.Suspend(entity.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
|
||||
}
|
||||
|
||||
private bool IsCurrent(
|
||||
LiveEntityRecord record,
|
||||
AcDream.Core.World.WorldEntity? entity) =>
|
||||
entity is not null
|
||||
&& _liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
||||
&& ReferenceEquals(current, record)
|
||||
&& ReferenceEquals(current.WorldEntity, entity);
|
||||
|
||||
private bool IsPlacementActive(LiveEntityRecord record) =>
|
||||
_activePlacementGenerationByGuid.TryGetValue(
|
||||
record.ServerGuid,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ public interface ILiveEntityRemotePlacementRuntime :
|
|||
Vector3 LastServerPosition { get; set; }
|
||||
double LastServerPositionTime { get; set; }
|
||||
Vector3 LastShadowSyncPosition { get; set; }
|
||||
Quaternion LastShadowSyncOrientation { get; set; }
|
||||
void HitGround();
|
||||
void LeaveGround();
|
||||
}
|
||||
|
|
@ -111,6 +112,13 @@ public sealed class LiveEntityRecord
|
|||
ServerGuid = snapshot.Guid;
|
||||
Snapshot = snapshot;
|
||||
RefreshDerivedState();
|
||||
PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL;
|
||||
VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL;
|
||||
VelocityAuthorityVersion = snapshot.Position is null
|
||||
&& snapshot.Physics is null
|
||||
? 0UL
|
||||
: 1UL;
|
||||
MovementAuthorityVersion = 1UL;
|
||||
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
|
||||
ApplyRawPhysicsState(RawPhysicsState);
|
||||
}
|
||||
|
|
@ -124,7 +132,29 @@ public sealed class LiveEntityRecord
|
|||
public uint CanonicalLandblockId { get; internal set; }
|
||||
public uint RawPhysicsState { get; internal set; }
|
||||
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
|
||||
/// <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; }
|
||||
internal bool PhysicsBodyAcquisitionInProgress { get; set; }
|
||||
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
||||
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
|
||||
internal bool RequiresRemotePlacementRuntime { get; set; }
|
||||
|
|
@ -134,6 +164,16 @@ public sealed class LiveEntityRecord
|
|||
public bool IsSpatiallyProjected { get; internal set; }
|
||||
public bool IsSpatiallyVisible { get; internal set; }
|
||||
internal ulong ProjectionMutationVersion { get; set; }
|
||||
/// <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 LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
||||
internal bool RuntimeComponentsTeardownCompleted { get; set; }
|
||||
|
|
@ -145,8 +185,21 @@ public sealed class LiveEntityRecord
|
|||
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
|
||||
_pendingStateTransitions.TryDequeue(out transition);
|
||||
|
||||
internal void SuspendObjectClock()
|
||||
{
|
||||
ObjectClock.Deactivate();
|
||||
ObjectClockEpoch++;
|
||||
}
|
||||
|
||||
internal void ResetObjectClockForEnterWorld(bool isStatic)
|
||||
{
|
||||
ObjectClock.ResetForEnterWorld(isStatic);
|
||||
ObjectClockEpoch++;
|
||||
}
|
||||
|
||||
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
|
||||
{
|
||||
StateAuthorityVersion++;
|
||||
RawPhysicsState = rawState;
|
||||
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
|
||||
FinalPhysicsState,
|
||||
|
|
@ -158,6 +211,33 @@ public sealed class LiveEntityRecord
|
|||
return transition;
|
||||
}
|
||||
|
||||
internal void AdvancePositionAuthority()
|
||||
{
|
||||
PositionAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal void AdvanceVectorAuthority()
|
||||
{
|
||||
VectorAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal void AdvanceMovementAuthority()
|
||||
{
|
||||
MovementAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal void AdvanceCreateAuthority()
|
||||
{
|
||||
PositionAuthorityVersion++;
|
||||
StateAuthorityVersion++;
|
||||
VectorAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
MovementAuthorityVersion++;
|
||||
}
|
||||
|
||||
internal void SetChildNoDraw(bool noDraw)
|
||||
{
|
||||
FinalPhysicsState = noDraw
|
||||
|
|
@ -241,6 +321,10 @@ public sealed class LiveEntityRuntime
|
|||
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _spatialAnimationsByLocalId = new();
|
||||
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _spatialRemoteMotionByGuid = 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 _sessionClearPendingFinalization;
|
||||
private bool _isRegisteringResources;
|
||||
|
|
@ -297,6 +381,8 @@ public sealed class LiveEntityRuntime
|
|||
_spatialRemoteMotionByGuid;
|
||||
internal IReadOnlyDictionary<uint, ILiveEntityProjectileRuntime> SpatialProjectileRuntimes =>
|
||||
_spatialProjectilesByGuid;
|
||||
internal IReadOnlyDictionary<uint, LiveEntityRecord> SpatialRootObjects =>
|
||||
_spatialRootObjectsByGuid;
|
||||
public ParentAttachmentState ParentAttachments { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -327,6 +413,7 @@ public sealed class LiveEntityRuntime
|
|||
{
|
||||
retained.Snapshot = result.Snapshot;
|
||||
retained.RefreshDerivedState();
|
||||
retained.AdvanceCreateAuthority();
|
||||
RefreshSpatialRuntimeIndexes(retained);
|
||||
return new LiveEntityRegistrationResult(result, retained, false, false);
|
||||
}
|
||||
|
|
@ -510,8 +597,18 @@ public sealed class LiveEntityRuntime
|
|||
record.WorldEntity!.IsAncestorDrawVisible = true;
|
||||
}
|
||||
RefreshPresentation(record);
|
||||
RebucketLiveEntity(serverGuid, fullCellId);
|
||||
return record.WorldEntity;
|
||||
WorldEntity materialized = record.WorldEntity!;
|
||||
if (!RebucketLiveEntity(serverGuid, fullCellId)
|
||||
|| !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current)
|
||||
|| !ReferenceEquals(current, record)
|
||||
|| !ReferenceEquals(current.WorldEntity, materialized))
|
||||
{
|
||||
// Visibility observers may replace this GUID while rebucketing.
|
||||
// A failed teardown can retain the displaced entity as a retry
|
||||
// tombstone; it is not the result of this materialization.
|
||||
return null;
|
||||
}
|
||||
return materialized;
|
||||
}
|
||||
|
||||
/// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary>
|
||||
|
|
@ -523,6 +620,10 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
bool wasProjected = record.IsSpatiallyProjected;
|
||||
bool wasVisible = record.IsSpatiallyVisible;
|
||||
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out LiveEntityRecord? indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record);
|
||||
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
||||
// GpuWorldState reports an intermediate false/true pair while moving
|
||||
// between two loaded buckets. Suppress those implementation details
|
||||
|
|
@ -570,6 +671,38 @@ public sealed class LiveEntityRuntime
|
|||
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
|
||||
? 0u
|
||||
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
|
||||
&& record.IsSpatiallyProjected
|
||||
&& record.IsSpatiallyVisible
|
||||
&& record.FullCellId != 0;
|
||||
// prepare_to_enter_world (0x00511FA0) writes update_time = cur_time.
|
||||
// Only a root-workset membership edge rebases the clock. Ordinary
|
||||
// loaded-to-loaded movement preserves it; parented/attached objects
|
||||
// take retail update_object's parent early-out and remain suspended.
|
||||
if (!wasProjected && !isOrdinaryRoot)
|
||||
{
|
||||
record.SuspendObjectClock();
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
}
|
||||
else if (wasOrdinaryRoot != isOrdinaryRoot)
|
||||
{
|
||||
if (isOrdinaryRoot)
|
||||
{
|
||||
// InitObjectBegin and every leave-visibility edge are inactive
|
||||
// before retail prepare_to_enter_world. Preserve that
|
||||
// prerequisite for a first materialization as well.
|
||||
if (!wasProjected)
|
||||
record.ObjectClock.Deactivate();
|
||||
record.ResetObjectClockForEnterWorld(
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0);
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
record.SuspendObjectClock();
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
}
|
||||
}
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
Exception? runtimeNotificationFailure = null;
|
||||
if (!wasProjected || wasVisible != visible)
|
||||
|
|
@ -583,6 +716,18 @@ public sealed class LiveEntityRuntime
|
|||
runtimeNotificationFailure = error;
|
||||
}
|
||||
}
|
||||
// Final observers are intentionally re-entrant. A callback can delete
|
||||
// and recreate this GUID or start a newer rebucket on the same record;
|
||||
// the outer caller must not continue with captures from that displaced
|
||||
// projection operation.
|
||||
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
||||
{
|
||||
ThrowAfterCommittedProjectionChange(
|
||||
serverGuid,
|
||||
spatialNotificationFailure,
|
||||
runtimeNotificationFailure);
|
||||
return false;
|
||||
}
|
||||
ThrowAfterCommittedProjectionChange(
|
||||
serverGuid,
|
||||
spatialNotificationFailure,
|
||||
|
|
@ -600,6 +745,11 @@ public sealed class LiveEntityRuntime
|
|||
|| record.WorldEntity is null)
|
||||
return false;
|
||||
|
||||
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out LiveEntityRecord? rootRecord)
|
||||
&& ReferenceEquals(rootRecord, record);
|
||||
ulong objectClockEpoch = record.ObjectClockEpoch;
|
||||
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
||||
Exception? spatialNotificationFailure = null;
|
||||
try
|
||||
|
|
@ -631,6 +781,14 @@ public sealed class LiveEntityRuntime
|
|||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||
record.IsSpatiallyProjected = false;
|
||||
record.IsSpatiallyVisible = false;
|
||||
// GpuWorldState normally publishes the leave edge synchronously and
|
||||
// OnSpatialVisibilityChanged suspends this exact clock there. During a
|
||||
// reentrant notification the edge is deferred, so commit the missing
|
||||
// suspension here. One root-workset leave is one epoch edge -- never
|
||||
// advance it once in the observer and again in this owner.
|
||||
if (wasOrdinaryRoot && record.ObjectClockEpoch == objectClockEpoch)
|
||||
record.SuspendObjectClock();
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
Exception? runtimeNotificationFailure = null;
|
||||
if (spatialEdgeWasDeferred)
|
||||
|
|
@ -644,6 +802,18 @@ public sealed class LiveEntityRuntime
|
|||
runtimeNotificationFailure = error;
|
||||
}
|
||||
}
|
||||
// The manually published deferred edge is an arbitrary callback
|
||||
// boundary just like Rebucket's final visibility edge. It may
|
||||
// reproject this record or replace the GUID; the displaced outer
|
||||
// withdrawal must not report itself as the current operation.
|
||||
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
||||
{
|
||||
ThrowAfterCommittedProjectionChange(
|
||||
serverGuid,
|
||||
spatialNotificationFailure,
|
||||
runtimeNotificationFailure);
|
||||
return false;
|
||||
}
|
||||
ThrowAfterCommittedProjectionChange(
|
||||
serverGuid,
|
||||
spatialNotificationFailure,
|
||||
|
|
@ -845,11 +1015,70 @@ public sealed class LiveEntityRuntime
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
||||
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
||||
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition.");
|
||||
PhysicsBody candidateBody = runtime.Body;
|
||||
if (record.PhysicsBody is { } canonicalBody
|
||||
&& !ReferenceEquals(canonicalBody, candidateBody))
|
||||
|
|
@ -868,6 +1097,7 @@ public sealed class LiveEntityRuntime
|
|||
record.RemoteMotionRuntime = runtime;
|
||||
record.PhysicsBody = candidateBody;
|
||||
candidateBody.State = record.FinalPhysicsState;
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
|
||||
{
|
||||
// Capture the incarnation, not only its GUID. A callback retained
|
||||
|
|
@ -919,6 +1149,9 @@ public sealed class LiveEntityRuntime
|
|||
throw new InvalidOperationException(
|
||||
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
|
||||
}
|
||||
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{serverGuid:X8} cannot bind projectile physics during physics-body acquisition.");
|
||||
PhysicsBody candidateBody = runtime.Body;
|
||||
if (record.PhysicsBody is { } canonicalBody
|
||||
&& !ReferenceEquals(canonicalBody, candidateBody))
|
||||
|
|
@ -930,6 +1163,7 @@ public sealed class LiveEntityRuntime
|
|||
record.ProjectileRuntime = runtime;
|
||||
record.PhysicsBody = candidateBody;
|
||||
candidateBody.State = record.FinalPhysicsState;
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
}
|
||||
|
||||
|
|
@ -999,6 +1233,8 @@ public sealed class LiveEntityRuntime
|
|||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.Guid, accepted);
|
||||
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
||||
record.AdvancePositionAuthority();
|
||||
ClearWorldCell(update.Guid);
|
||||
ParentAttachments.EndChildProjection(update.Guid);
|
||||
}
|
||||
|
|
@ -1011,6 +1247,8 @@ public sealed class LiveEntityRuntime
|
|||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.ChildGuid, accepted);
|
||||
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
|
||||
record.AdvancePositionAuthority();
|
||||
ClearWorldCell(update.ChildGuid);
|
||||
}
|
||||
return applied;
|
||||
|
|
@ -1022,6 +1260,8 @@ public sealed class LiveEntityRuntime
|
|||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.ChildGuid, accepted);
|
||||
if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record))
|
||||
record.AdvancePositionAuthority();
|
||||
ClearWorldCell(update.ChildGuid);
|
||||
}
|
||||
return applied;
|
||||
|
|
@ -1036,13 +1276,24 @@ public sealed class LiveEntityRuntime
|
|||
bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
|
||||
if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
||||
RefreshRecord(update.Guid, snapshot);
|
||||
if (applied
|
||||
&& retainPayload
|
||||
&& _recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
||||
{
|
||||
record.AdvanceMovementAuthority();
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
bool applied = _inbound.TryApplyVector(update, out accepted);
|
||||
if (applied) RefreshRecord(update.Guid, accepted);
|
||||
if (applied)
|
||||
{
|
||||
RefreshRecord(update.Guid, accepted);
|
||||
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
||||
record.AdvanceVectorAuthority();
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
|
|
@ -1117,6 +1368,13 @@ public sealed class LiveEntityRuntime
|
|||
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
|
||||
if (acceptedPosition)
|
||||
{
|
||||
if (_recordsByGuid.TryGetValue(
|
||||
update.Guid,
|
||||
out LiveEntityRecord? acceptedRecord)
|
||||
&& ReferenceEquals(acceptedRecord, beforePosition))
|
||||
{
|
||||
acceptedRecord.AdvancePositionAuthority();
|
||||
}
|
||||
ParentAttachments.EndChildProjection(update.Guid);
|
||||
}
|
||||
}
|
||||
|
|
@ -1136,12 +1394,231 @@ public sealed class LiveEntityRuntime
|
|||
/// script/effect owners but clears the cell and withdraws the root
|
||||
/// projection until a fresh Position re-enters it.
|
||||
/// </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) =>
|
||||
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||
&& record.IsSpatiallyProjected
|
||||
&& record.IsSpatiallyVisible
|
||||
&& record.FullCellId != 0
|
||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
|
||||
GetRootObjectClockDisposition(serverGuid)
|
||||
is RetailObjectClockDisposition.Advance;
|
||||
|
||||
internal bool IsCurrentPositionAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, record)
|
||||
&& current.PositionAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentStateAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, record)
|
||||
&& current.StateAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentVectorAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, record)
|
||||
&& current.VectorAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentVelocityAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, record)
|
||||
&& current.VelocityAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentMovementAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
||||
&& ReferenceEquals(current, record)
|
||||
&& current.MovementAuthorityVersion == authorityVersion;
|
||||
|
||||
/// <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>
|
||||
/// Copies the currently spatial remote-motion owners into caller-owned
|
||||
|
|
@ -1393,6 +1870,19 @@ public sealed class LiveEntityRuntime
|
|||
{
|
||||
if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
|
||||
return;
|
||||
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
|
||||
guid,
|
||||
out LiveEntityRecord? indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record);
|
||||
// Pickup/Parent is retail's leave-world edge. Suspend the canonical
|
||||
// object clock while the record is still indexed as a root; clearing
|
||||
// FullCellId first removes that evidence and makes the later projection
|
||||
// withdrawal look like a duplicate non-root edge.
|
||||
if (wasOrdinaryRoot)
|
||||
{
|
||||
record.SuspendObjectClock();
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
}
|
||||
record.FullCellId = 0;
|
||||
record.CanonicalLandblockId = 0;
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
|
|
@ -1404,6 +1894,7 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) =>
|
||||
record.WorldEntity is not null
|
||||
&& record.ProjectionKind is LiveEntityProjectionKind.World
|
||||
&& record.IsSpatiallyProjected
|
||||
&& record.IsSpatiallyVisible
|
||||
&& record.FullCellId != 0;
|
||||
|
|
@ -1413,6 +1904,17 @@ public sealed class LiveEntityRuntime
|
|||
bool current = IsCurrentRecord(record);
|
||||
bool spatial = current && HasSpatialRuntimeProjection(record);
|
||||
|
||||
if (spatial)
|
||||
{
|
||||
_spatialRootObjectsByGuid[record.ServerGuid] = record;
|
||||
}
|
||||
else if (current
|
||||
|| (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record)))
|
||||
{
|
||||
_spatialRootObjectsByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
|
||||
if (record.WorldEntity is { } entity)
|
||||
{
|
||||
if (spatial && record.AnimationRuntime is { } animation)
|
||||
|
|
@ -1455,6 +1957,12 @@ public sealed class LiveEntityRuntime
|
|||
|
||||
private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record)
|
||||
{
|
||||
if (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record))
|
||||
{
|
||||
_spatialRootObjectsByGuid.Remove(record.ServerGuid);
|
||||
}
|
||||
|
||||
if (record.WorldEntity is { } entity
|
||||
&& _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation)
|
||||
&& ReferenceEquals(indexedAnimation, record.AnimationRuntime))
|
||||
|
|
@ -1506,13 +2014,46 @@ public sealed class LiveEntityRuntime
|
|||
return;
|
||||
|
||||
bool wasVisible = record.IsSpatiallyVisible;
|
||||
bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue(
|
||||
serverGuid,
|
||||
out LiveEntityRecord? indexedRoot)
|
||||
&& ReferenceEquals(indexedRoot, record);
|
||||
record.IsSpatiallyVisible = visible;
|
||||
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
|
||||
&& record.IsSpatiallyProjected
|
||||
&& visible
|
||||
&& record.FullCellId != 0;
|
||||
if (_rebucketingGuid != serverGuid && wasOrdinaryRoot != isOrdinaryRoot)
|
||||
{
|
||||
if (isOrdinaryRoot)
|
||||
record.ResetObjectClockForEnterWorld(
|
||||
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0);
|
||||
else
|
||||
record.SuspendObjectClock();
|
||||
SynchronizePhysicsBodyActiveState(record);
|
||||
}
|
||||
RefreshSpatialRuntimeIndexes(record);
|
||||
RefreshPresentation(record);
|
||||
if (_rebucketingGuid != serverGuid && wasVisible != visible)
|
||||
PublishProjectionVisibilityChanged(record, visible);
|
||||
}
|
||||
|
||||
private static void SynchronizePhysicsBodyActiveState(
|
||||
LiveEntityRecord record)
|
||||
{
|
||||
if (record.PhysicsBody is not { } body)
|
||||
return;
|
||||
if (record.ObjectClock.IsActive)
|
||||
body.TransientState |= TransientStateFlags.Active;
|
||||
else
|
||||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector3 value) =>
|
||||
float.IsFinite(value.X)
|
||||
&& float.IsFinite(value.Y)
|
||||
&& float.IsFinite(value.Z);
|
||||
|
||||
private void PublishProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||
{
|
||||
Delegate[] subscribers = ProjectionVisibilityChanged?.GetInvocationList()
|
||||
|
|
@ -1617,6 +2158,7 @@ public sealed class LiveEntityRuntime
|
|||
_spatialAnimationsByLocalId.Clear();
|
||||
_spatialRemoteMotionByGuid.Clear();
|
||||
_spatialProjectilesByGuid.Clear();
|
||||
_spatialRootObjectsByGuid.Clear();
|
||||
ParentAttachments.Clear();
|
||||
_inbound.Clear();
|
||||
_spatial.ClearLiveEntityLifetimeState();
|
||||
|
|
|
|||
|
|
@ -52,21 +52,25 @@ public sealed class LiveEntityAnimationRuntimeView<TAnimation>
|
|||
&& 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()
|
||||
{
|
||||
_iterationSnapshot.Clear();
|
||||
LiveEntityRuntime? runtime = _runtime();
|
||||
if (runtime is not null)
|
||||
{
|
||||
foreach (var pair in runtime.SpatialAnimationRuntimes)
|
||||
{
|
||||
if (pair.Value is TAnimation typed)
|
||||
{
|
||||
_iterationSnapshot.Add(
|
||||
new KeyValuePair<uint, TAnimation>(pair.Key, typed));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (runtime is null)
|
||||
_iterationSnapshot.Clear();
|
||||
else
|
||||
runtime.CopySpatialAnimationRuntimesTo(_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>
|
||||
/// Crucially this is **not** per-node: while a link animation plays, the
|
||||
/// surfaced velocity is still the cycle's velocity (the cycle was added
|
||||
/// last, so SetVelocity's latest call wins). Remote entity dead-reckoning
|
||||
/// reads this to integrate position without gapping during stance
|
||||
/// transitions.
|
||||
/// last, so SetVelocity's latest call wins). <c>CSequence::apply_physics</c>
|
||||
/// contributes it directly to the complete root Frame; no separate body-
|
||||
/// velocity reconstruction is performed from this accessor.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Vector3 CurrentVelocity => _core.Velocity;
|
||||
|
||||
/// <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>
|
||||
public Vector3 CurrentOmega => _core.Omega;
|
||||
|
||||
|
|
@ -283,6 +284,12 @@ public sealed class AnimationSequencer
|
|||
_table = new CMotionTable(motionTable);
|
||||
_state = new MotionState();
|
||||
_manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this));
|
||||
|
||||
// CPartArray::InitDefaults (0x00518980) runs for every setup-backed
|
||||
// PartArray, before CPhysicsObj::InitDefaults installs its motion
|
||||
// table. Static is only a later workset-membership decision; it does
|
||||
// not gate installation of Setup.DefaultAnimation itself.
|
||||
InitializeSetupDefaultAnimation((uint)setup.DefaultAnimation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -399,38 +406,9 @@ public sealed class AnimationSequencer
|
|||
if (dispatchResult != MotionTableManagerError.Success)
|
||||
return;
|
||||
|
||||
// ── Synthesize CurrentOmega for turn cycles ───────────────────────
|
||||
// Humanoid turn MotionData often ships without HasOmega. Until the
|
||||
// remaining R6 rotation path consumes CSequence's complete Frame
|
||||
// orientation, the remote ObservedOmega seam needs the retail
|
||||
// turn-rate fallback. Decompile references:
|
||||
// FUN_00529210 apply_current_movement (writes Omega)
|
||||
// chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1)
|
||||
// The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left
|
||||
// turns (holtburger confirms the same via motion_resolution.rs).
|
||||
if (_core.Omega.LengthSquared() < 1e-9f)
|
||||
{
|
||||
float zomega = 0f;
|
||||
uint low = motion & 0xFFu;
|
||||
switch (low)
|
||||
{
|
||||
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
|
||||
zomega = -(MathF.PI / 2f) * adjustedSpeed;
|
||||
break;
|
||||
case 0x0E: // TurnLeft — counter-clockwise = +Z.
|
||||
// adjust_motion above ALREADY remapped 0x0E → 0x0D
|
||||
// with adjustedSpeed = -speedMod, so the same
|
||||
// formula as 0x0D applied to the negated speed
|
||||
// produces the correct +Z (CCW) result. Using a
|
||||
// different sign here would double-negate and
|
||||
// animate a left turn as a right turn — that was
|
||||
// the bug observed before this fix (commit follows).
|
||||
zomega = -(MathF.PI / 2f) * adjustedSpeed;
|
||||
break;
|
||||
}
|
||||
if (zomega != 0f)
|
||||
_core.SetOmega(new Vector3(0f, 0f, zomega));
|
||||
}
|
||||
// Rotation stays DAT-authored. Retail MotionData::add_motion
|
||||
// (0x005224B0) contributes the entry's literal omega to CSequence;
|
||||
// CSequence::apply_physics emits it through the complete Frame.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -453,6 +431,36 @@ public sealed class AnimationSequencer
|
|||
/// </summary>
|
||||
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>
|
||||
/// R2-Q5: the single dispatch entry — lazy initialize_state, then
|
||||
/// <see cref="MotionTableManager.PerformMovement"/>. The resulting
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
|
|
@ -15,15 +16,9 @@ namespace AcDream.Core.Physics;
|
|||
// InterpolationManager::NodeCompleted acclient @ 0x005559A0
|
||||
// InterpolationManager::StopInterpolating acclient @ 0x00555950
|
||||
//
|
||||
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes
|
||||
// current body position + max-speed from the motion table; we return the
|
||||
// world-space delta vector to apply to the body for this frame.
|
||||
//
|
||||
// Public C# API kept Vector3-based for compatibility with PositionManager and
|
||||
// GameWindow callsites; retail-spec method names are documented inline. The
|
||||
// retail Frame mutation pattern collapses to "return a Vector3 delta" because
|
||||
// adjust_offset's offset Frame is rotation-zero (translation-only) for this
|
||||
// queue's purposes — see audit 04-interp-manager.md § 4.
|
||||
// FIFO Position-waypoint queue (cap 20). The compatibility overload returns
|
||||
// only its world-space origin, while the production overload carries retail's
|
||||
// complete relative Frame from Position::subtract2, including orientation.
|
||||
//
|
||||
// Bug fixes applied vs prior port (audit § 7):
|
||||
// #1: progress_quantum accumulates dt (not step magnitude).
|
||||
|
|
@ -38,8 +33,7 @@ namespace AcDream.Core.Physics;
|
|||
internal sealed class InterpolationNode
|
||||
{
|
||||
public Vector3 TargetPosition;
|
||||
public float Heading;
|
||||
public bool IsHeadingValid;
|
||||
public Quaternion TargetOrientation = Quaternion.Identity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -120,6 +114,7 @@ public sealed class InterpolationManager
|
|||
private float _progressQuantum = 0f; // progress_quantum (sum of dt)
|
||||
private float _originalDistance = OriginalDistanceSentinel; // original_distance
|
||||
private int _failCount = 0; // node_fail_counter
|
||||
private bool _keepHeading; // keep_heading
|
||||
|
||||
// ── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -167,11 +162,31 @@ public sealed class InterpolationManager
|
|||
/// Pass <c>null</c> if not available — far/near classification falls back
|
||||
/// to "near" (no pre-armed blip).
|
||||
/// </param>
|
||||
public void Enqueue(
|
||||
public Quaternion? Enqueue(
|
||||
Vector3 targetPosition,
|
||||
float heading,
|
||||
bool isMovingTo,
|
||||
Vector3? currentBodyPosition = null)
|
||||
=> Enqueue(
|
||||
targetPosition,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, heading),
|
||||
isMovingTo,
|
||||
currentBodyPosition,
|
||||
currentBodyOrientation: null);
|
||||
|
||||
/// <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
|
||||
// (if tail exists AND tail->type == 1) or the body's m_position.
|
||||
|
|
@ -195,10 +210,17 @@ public sealed class InterpolationManager
|
|||
// Far branch (retail line 352918, dist > GetAutonomyBlipDistance):
|
||||
if (dist > AutonomyBlipDistance)
|
||||
{
|
||||
EnqueueRaw(targetPosition, heading, isMovingTo);
|
||||
// The far branch does not assign keep_heading from arg3. It uses
|
||||
// the manager's existing flag when storing this Position.
|
||||
EnqueueRaw(
|
||||
targetPosition,
|
||||
StoreTargetOrientation(
|
||||
targetOrientation,
|
||||
currentBodyOrientation,
|
||||
_keepHeading));
|
||||
// Pre-arm immediate blip on next AdjustOffset (audit § 7 #3).
|
||||
_failCount = StallFailCountThreshold + 1;
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Near & already-close branch (retail line 352962):
|
||||
|
|
@ -209,7 +231,14 @@ public sealed class InterpolationManager
|
|||
if (bodyDist <= DesiredDistance)
|
||||
{
|
||||
Clear();
|
||||
return;
|
||||
// InterpolateTo 0x00555C08 calls CPhysicsObj::set_heading
|
||||
// with the target Frame's heading. It does not install the
|
||||
// target's pitch/roll at this already-close seam.
|
||||
return isMovingTo
|
||||
? null
|
||||
: MoveToMath.SetHeading(
|
||||
targetOrientation,
|
||||
MoveToMath.GetHeading(targetOrientation));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -227,19 +256,43 @@ public sealed class InterpolationManager
|
|||
_queue.RemoveFirst();
|
||||
|
||||
// 3. Append.
|
||||
EnqueueRaw(targetPosition, heading, isMovingTo);
|
||||
_keepHeading = isMovingTo;
|
||||
EnqueueRaw(
|
||||
targetPosition,
|
||||
StoreTargetOrientation(
|
||||
targetOrientation,
|
||||
currentBodyOrientation,
|
||||
_keepHeading));
|
||||
return null;
|
||||
}
|
||||
|
||||
private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo)
|
||||
private void EnqueueRaw(
|
||||
Vector3 target,
|
||||
Quaternion targetOrientation)
|
||||
{
|
||||
_queue.AddLast(new InterpolationNode
|
||||
{
|
||||
TargetPosition = target,
|
||||
Heading = heading,
|
||||
IsHeadingValid = isMovingTo,
|
||||
TargetOrientation = targetOrientation,
|
||||
});
|
||||
}
|
||||
|
||||
private static Quaternion StoreTargetOrientation(
|
||||
Quaternion targetOrientation,
|
||||
Quaternion? currentBodyOrientation,
|
||||
bool keepHeading)
|
||||
{
|
||||
if (!keepHeading || currentBodyOrientation is not { } current)
|
||||
return targetOrientation;
|
||||
|
||||
// InterpolateTo stores the object's current heading into the node
|
||||
// when keep_heading is active. Frame::set_heading intentionally
|
||||
// discards the target Position's pitch/roll at this seam.
|
||||
return MoveToMath.SetHeading(
|
||||
targetOrientation,
|
||||
MoveToMath.GetHeading(current));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute the per-frame world-space correction delta. Combines the retail
|
||||
/// <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).
|
||||
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
|
||||
/// </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.
|
||||
if (dt <= 0 || double.IsNaN(dt))
|
||||
return Vector3.Zero;
|
||||
return default;
|
||||
|
||||
if (_queue.First is null)
|
||||
return Vector3.Zero;
|
||||
return default;
|
||||
|
||||
// Distance to head node (retail line 353083).
|
||||
var head = _queue.First.Value;
|
||||
|
|
@ -278,7 +391,7 @@ public sealed class InterpolationManager
|
|||
if (dist <= DesiredDistance)
|
||||
{
|
||||
NodeCompleted(popHead: true, currentBodyPosition);
|
||||
return Vector3.Zero;
|
||||
return default;
|
||||
}
|
||||
|
||||
// Catch-up speed (retail line 353122 + 353128 fallback).
|
||||
|
|
@ -344,9 +457,13 @@ public sealed class InterpolationManager
|
|||
// Retail splits this into a separate UseTime call; we collapse it.
|
||||
if (_failCount > StallFailCountThreshold)
|
||||
{
|
||||
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
|
||||
InterpolationNode tail = _queue.Last!.Value;
|
||||
Vector3 tailDelta = tail.TargetPosition - currentBodyPosition;
|
||||
Clear();
|
||||
return tailPos - currentBodyPosition;
|
||||
return new InterpolationStep(
|
||||
true,
|
||||
tailDelta,
|
||||
tail.TargetOrientation);
|
||||
}
|
||||
|
||||
// Per-frame step magnitude (retail line 353218).
|
||||
|
|
@ -358,9 +475,17 @@ public sealed class InterpolationManager
|
|||
|
||||
// Direction × step.
|
||||
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step;
|
||||
return delta;
|
||||
return new InterpolationStep(
|
||||
true,
|
||||
delta,
|
||||
head.TargetOrientation);
|
||||
}
|
||||
|
||||
private readonly record struct InterpolationStep(
|
||||
bool Overwrites,
|
||||
Vector3 WorldOrigin,
|
||||
Quaternion TargetOrientation);
|
||||
|
||||
/// <summary>
|
||||
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
|
||||
/// popHead=false during stall fail (re-baseline only). For our collapsed
|
||||
|
|
|
|||
|
|
@ -25,6 +25,48 @@ public static class FrameOps
|
|||
/// square against |v|².</summary>
|
||||
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>
|
||||
public static void GRotate(Frame frame, Vector3 rotationGlobal)
|
||||
{
|
||||
|
|
@ -48,7 +90,10 @@ public static class FrameOps
|
|||
rotationGlobal.Y * s * invMag,
|
||||
rotationGlobal.Z * s * invMag,
|
||||
c);
|
||||
frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation));
|
||||
frame.Orientation = SetRotate(
|
||||
frame.Origin,
|
||||
frame.Orientation,
|
||||
Quaternion.Multiply(r, frame.Orientation));
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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>
|
||||
|
|
@ -75,7 +123,9 @@ public static class FrameOps
|
|||
/// </summary>
|
||||
public static void Subtract1(Frame frame, Frame pos)
|
||||
{
|
||||
frame.Orientation = Quaternion.Normalize(
|
||||
frame.Orientation = SetRotate(
|
||||
frame.Origin,
|
||||
frame.Orientation,
|
||||
frame.Orientation * Quaternion.Conjugate(pos.Orientation));
|
||||
frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ namespace AcDream.Core.Physics.Motion;
|
|||
///
|
||||
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
|
||||
/// position delta, in the mover's LOCAL frame after
|
||||
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
|
||||
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
|
||||
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
|
||||
/// (P5 convention, degrees).</para>
|
||||
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> is the full
|
||||
/// relative quaternion carried by retail <c>Frame</c>; the heading accessors
|
||||
/// are narrow helpers for managers that explicitly call
|
||||
/// <c>Frame::set_heading</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class MotionDeltaFrame
|
||||
{
|
||||
|
|
@ -26,10 +26,50 @@ public sealed class MotionDeltaFrame
|
|||
/// delta (mover-local frame).</summary>
|
||||
public Vector3 Origin;
|
||||
|
||||
/// <summary>Retail <c>Frame</c> rotation — carries the
|
||||
/// <c>Frame::set_heading</c> output.</summary>
|
||||
/// <summary>Retail <c>Frame</c>'s complete relative rotation.</summary>
|
||||
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>
|
||||
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
|
||||
/// AP-73 row approximated with one cycle.
|
||||
/// </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>
|
||||
public sealed class MotionTableDispatchSink : IInterpretedMotionSink
|
||||
{
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
_sequencer = sequencer;
|
||||
}
|
||||
|
||||
private static bool IsTurn(uint motion)
|
||||
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
|
||||
|
||||
public bool ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
if (IsTurn(motion))
|
||||
TurnApplied?.Invoke(motion, speed);
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
|
||||
public bool StopMotion(uint motion)
|
||||
{
|
||||
if (IsTurn(motion))
|
||||
TurnStopped?.Invoke();
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
|
|
@ -75,14 +48,10 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink
|
|||
/// (0x00518890): <c>MotionTableManager::PerformMovement(type 5)</c>.
|
||||
/// The manager stops everything and queues its Ready-sentinel
|
||||
/// pending_animations entry UNCONDITIONALLY — the completable partner
|
||||
/// of the interp's A9 pending_motions node. A full stop ends any turn
|
||||
/// cycle, so the ObservedOmega seam is notified like
|
||||
/// <see cref="StopMotion"/>'s turn-class branch.
|
||||
/// of the interp's A9 pending_motions node.
|
||||
/// </summary>
|
||||
public bool StopCompletely()
|
||||
{
|
||||
TurnStopped?.Invoke();
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely());
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,12 +114,8 @@ public static class MoveToMath
|
|||
/// <summary>
|
||||
/// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) —
|
||||
/// extracts the compass heading (P5 convention) from a body orientation
|
||||
/// quaternion. <b>The packer-reuse trap (V0-pins §P5 correction):</b>
|
||||
/// acdream's outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is
|
||||
/// 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
|
||||
/// quaternion. This method uses the scalar bridge derived from acdream's
|
||||
/// body convention
|
||||
/// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation =
|
||||
/// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X):
|
||||
/// world-forward = <c>(cos Yaw, sin Yaw)</c>, so
|
||||
|
|
@ -166,11 +162,10 @@ public static class MoveToMath
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5: the scalar leg of <see cref="GetHeading"/> for bodies whose
|
||||
/// authoritative facing is a yaw ANGLE rather than a quaternion (the
|
||||
/// local player: <c>PlayerMovementController.Yaw</c>, radians, Yaw=0
|
||||
/// faces +X, re-synced into the body quaternion every Update). Same P5
|
||||
/// bridge: <c>heading = (90 - yawDeg) mod 360</c>.
|
||||
/// R4-V5: scalar projection of <see cref="GetHeading"/>. The local
|
||||
/// player's <c>Yaw</c> property exposes this horizontal projection while
|
||||
/// its complete body quaternion remains authoritative. Same P5 bridge:
|
||||
/// <c>heading = (90 - yawDeg) mod 360</c>.
|
||||
/// </summary>
|
||||
public static float HeadingFromYaw(float yawRad)
|
||||
{
|
||||
|
|
@ -181,10 +176,10 @@ public static class MoveToMath
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the
|
||||
/// <c>set_heading</c> seam for yaw-authoritative bodies (the local
|
||||
/// player's heading snap must write <c>Yaw</c>, NOT the body
|
||||
/// quaternion, which the controller re-derives from Yaw every frame).
|
||||
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the scalar
|
||||
/// bridge used by an explicit <c>Frame::set_heading</c> operation. This
|
||||
/// operation deliberately replaces pitch/roll; ordinary frame composition
|
||||
/// does not pass through this projection.
|
||||
/// Returns radians wrapped to [-π, π] matching the controller's own
|
||||
/// wrap discipline.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ namespace AcDream.Core.Physics.Motion;
|
|||
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
|
||||
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
|
||||
///
|
||||
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b>
|
||||
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active
|
||||
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade
|
||||
/// route never re-asserted it — status quo preserved (zero-behavior-change
|
||||
/// slice), not a new deviation.</para>
|
||||
/// <para><b>Activation.</b> Retail begins
|
||||
/// <c>MovementManager::PerformMovement</c> (0x005240D0, call at 0x005240D9)
|
||||
/// with an unconditional <c>CPhysicsObj::set_active(1)</c>, before validating
|
||||
/// the movement type. <see cref="ActivatePhysicsObject"/> is the host seam for
|
||||
/// that call. Static objects keep retail's no-op behavior in the host.</para>
|
||||
/// </summary>
|
||||
public sealed class MovementManager
|
||||
{
|
||||
|
|
@ -73,6 +73,14 @@ public sealed class MovementManager
|
|||
/// <c>CPhysicsObj</c>.</summary>
|
||||
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)
|
||||
{
|
||||
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
|
||||
|
|
@ -102,6 +110,8 @@ public sealed class MovementManager
|
|||
/// </summary>
|
||||
public WeenieError PerformMovement(MovementStruct mvs)
|
||||
{
|
||||
ActivatePhysicsObject?.Invoke();
|
||||
|
||||
switch (mvs.Type)
|
||||
{
|
||||
case MovementType.RawCommand:
|
||||
|
|
|
|||
|
|
@ -774,7 +774,7 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
/// is set). Register row: releases a "stuck to object" sticky-manager
|
||||
/// attachment — R5 wires the real StickyManager; until then this is an
|
||||
/// optional callback the App layer may bind, matching the existing
|
||||
/// <c>Action?</c> seam convention (see <c>MotionTableDispatchSink.TurnStopped</c>).
|
||||
/// <c>Action?</c> seams on this runtime owner.
|
||||
/// </summary>
|
||||
public Action? UnstickFromObject { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ public static class PhysicsObjUpdate
|
|||
/// must therefore observe any velocity change made by the movement
|
||||
/// callback; moving that callback after reflection changes the result.
|
||||
/// </remarks>
|
||||
public static void CommitSetPositionTransition(
|
||||
public static bool CommitSetPositionTransition(
|
||||
PhysicsBody body,
|
||||
bool inContact,
|
||||
bool onWalkable,
|
||||
|
|
@ -68,7 +68,9 @@ public static class PhysicsObjUpdate
|
|||
bool previousContact,
|
||||
bool previousOnWalkable,
|
||||
Action? hitGround = null,
|
||||
Action? leaveGround = null)
|
||||
Action? leaveGround = null,
|
||||
Func<bool>? isCurrent = null,
|
||||
Func<bool>? isVelocityCurrent = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
|
||||
|
|
@ -95,11 +97,26 @@ public static class PhysicsObjUpdate
|
|||
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
||||
|
||||
if (!previousOnWalkable && finalOnWalkable)
|
||||
{
|
||||
hitGround?.Invoke();
|
||||
if (isCurrent?.Invoke() == false)
|
||||
return false;
|
||||
}
|
||||
else if (previousOnWalkable && !finalOnWalkable)
|
||||
{
|
||||
leaveGround?.Invoke();
|
||||
if (isCurrent?.Invoke() == false)
|
||||
return false;
|
||||
}
|
||||
body.calc_acceleration();
|
||||
|
||||
// Position, Vector, and Movement are independently timestamped but
|
||||
// can all install m_velocityVector. If a later one arrived from a
|
||||
// callback above, retain its vector and finish the non-overlapping
|
||||
// contact/pose commit without applying this older collision response.
|
||||
if (isVelocityCurrent?.Invoke() == false)
|
||||
return isCurrent?.Invoke() ?? true;
|
||||
|
||||
HandleAllCollisions(
|
||||
body,
|
||||
collisionNormalValid,
|
||||
|
|
@ -107,6 +124,7 @@ public static class PhysicsObjUpdate
|
|||
previousContact,
|
||||
previousOnWalkable,
|
||||
finalOnWalkable);
|
||||
return isCurrent?.Invoke() ?? true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,39 @@ public readonly record struct ProjectileAdvanceResult(
|
|||
Vector3 CollisionNormal,
|
||||
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>
|
||||
/// Core-only port of the retail live-projectile physics driver. It owns no
|
||||
/// renderer, network, or world-lifetime state: the App controller supplies a
|
||||
|
|
@ -152,32 +185,84 @@ public sealed class ProjectilePhysicsStepper
|
|||
transitionOk);
|
||||
}
|
||||
|
||||
private void StepQuantum(
|
||||
/// <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,
|
||||
float dt,
|
||||
ref uint cellId,
|
||||
float quantum,
|
||||
uint cellId,
|
||||
ProjectileCollisionSphere sphere,
|
||||
uint movingEntityId,
|
||||
uint designatedTargetId,
|
||||
ref bool collisionNormalValid,
|
||||
ref Vector3 collisionNormal,
|
||||
ref bool transitionOk)
|
||||
uint designatedTargetId = 0,
|
||||
bool isParented = false)
|
||||
{
|
||||
ProjectileQuantumPreparation preparation = BeginQuantum(
|
||||
body,
|
||||
quantum,
|
||||
cellId,
|
||||
sphere,
|
||||
isParented);
|
||||
return CompleteQuantum(
|
||||
body,
|
||||
preparation,
|
||||
sphere,
|
||||
movingEntityId,
|
||||
designatedTargetId);
|
||||
}
|
||||
|
||||
public ProjectileQuantumPreparation BeginQuantum(
|
||||
PhysicsBody body,
|
||||
float quantum,
|
||||
uint cellId,
|
||||
ProjectileCollisionSphere sphere,
|
||||
bool isParented = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
if (!float.IsFinite(quantum)
|
||||
|| quantum <= PhysicsGlobals.EPSILON
|
||||
|| quantum > PhysicsBody.MaxQuantum)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(quantum),
|
||||
quantum,
|
||||
"An admitted object quantum must be finite and no larger than MaxQuantum.");
|
||||
}
|
||||
|
||||
if (!sphere.IsValid)
|
||||
return NotSimulated(cellId, quantum);
|
||||
|
||||
if (isParented || !body.InWorld
|
||||
|| body.State.HasFlag(PhysicsStateFlags.Frozen)
|
||||
|| body.State.HasFlag(PhysicsStateFlags.Static)
|
||||
|| cellId == 0)
|
||||
{
|
||||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
return NotSimulated(cellId, quantum);
|
||||
}
|
||||
|
||||
if (!body.IsActive)
|
||||
return NotSimulated(cellId, quantum);
|
||||
|
||||
Vector3 beginPosition = body.Position;
|
||||
Quaternion beginOrientation = body.Orientation;
|
||||
Position beginCellPosition = body.CellPosition;
|
||||
bool beginInWorld = body.InWorld;
|
||||
ProjectileQuantumDynamics beginDynamics = CaptureDynamics(body);
|
||||
bool previousContact = body.InContact;
|
||||
bool previousOnWalkable = body.OnWalkable;
|
||||
|
||||
// Final PhysicsState drives acceleration on every quantum. Network
|
||||
// acceleration is retained by the protocol parser but not installed.
|
||||
body.calc_acceleration();
|
||||
body.UpdatePhysicsInternal(dt);
|
||||
body.UpdatePhysicsInternal(quantum);
|
||||
|
||||
Vector3 candidatePosition = body.Position;
|
||||
Quaternion candidateOrientation = body.Orientation;
|
||||
Vector3 displacement = candidatePosition - beginPosition;
|
||||
bool candidateMoved = displacement != Vector3.Zero;
|
||||
|
||||
if (candidateMoved && body.State.HasFlag(PhysicsStateFlags.AlignPath))
|
||||
{
|
||||
candidateOrientation = RetailFrameMath.SetVectorHeading(
|
||||
|
|
@ -189,76 +274,218 @@ public sealed class ProjectilePhysicsStepper
|
|||
{
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
body.Orientation = candidateOrientation;
|
||||
return;
|
||||
body.LastUpdateTime += quantum;
|
||||
}
|
||||
|
||||
// The integrator produces a candidate without committing the root.
|
||||
// Restore the authoritative start frame before transition setup so
|
||||
// carried cell-relative position and self-shadow identity remain exact.
|
||||
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;
|
||||
}
|
||||
|
||||
ProjectileQuantumDynamics candidateDynamics = CaptureDynamics(body);
|
||||
RestoreBeginFrame(
|
||||
body,
|
||||
beginPosition,
|
||||
beginOrientation,
|
||||
beginCellPosition,
|
||||
beginInWorld);
|
||||
ApplyDynamics(body, beginDynamics);
|
||||
|
||||
return new ProjectileQuantumPreparation(
|
||||
cellId,
|
||||
quantum,
|
||||
Simulated: true,
|
||||
RequiresTransition: candidateMoved,
|
||||
beginPosition,
|
||||
beginOrientation,
|
||||
beginCellPosition,
|
||||
beginInWorld,
|
||||
beginDynamics,
|
||||
candidatePosition,
|
||||
candidateOrientation,
|
||||
candidateDynamics,
|
||||
previousContact,
|
||||
previousOnWalkable);
|
||||
}
|
||||
|
||||
public ProjectileAdvanceResult CompleteQuantum(
|
||||
PhysicsBody body,
|
||||
in ProjectileQuantumPreparation preparation,
|
||||
ProjectileCollisionSphere sphere,
|
||||
uint movingEntityId,
|
||||
uint designatedTargetId = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
if (!preparation.Simulated)
|
||||
return new ProjectileAdvanceResult(preparation.CellId, 0, false, false, default, true);
|
||||
ApplyDynamics(body, preparation.CandidateDynamics);
|
||||
if (!preparation.RequiresTransition)
|
||||
{
|
||||
body.SetFrameInCurrentCell(
|
||||
preparation.CandidatePosition,
|
||||
preparation.CandidateOrientation);
|
||||
return new ProjectileAdvanceResult(preparation.CellId, 1, true, false, default, true);
|
||||
}
|
||||
|
||||
uint cellId = preparation.CellId;
|
||||
ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped)
|
||||
? ObjectInfoState.PathClipped
|
||||
: ObjectInfoState.None;
|
||||
var resolved = _physics.ResolveWithTransition(
|
||||
beginPosition,
|
||||
candidatePosition,
|
||||
preparation.BeginPosition,
|
||||
preparation.CandidatePosition,
|
||||
cellId,
|
||||
sphereRadius: sphere.Radius,
|
||||
sphereHeight: 0f,
|
||||
stepUpHeight: PhysicsGlobals.DefaultStepHeight,
|
||||
stepDownHeight: 0f,
|
||||
isOnGround: previousOnWalkable,
|
||||
isOnGround: preparation.PreviousOnWalkable,
|
||||
body: body,
|
||||
moverFlags: moverFlags,
|
||||
movingEntityId: movingEntityId,
|
||||
localSphereOrigin: sphere.LocalOrigin,
|
||||
beginOrientation: beginOrientation,
|
||||
endOrientation: candidateOrientation,
|
||||
beginOrientation: preparation.BeginOrientation,
|
||||
endOrientation: preparation.CandidateOrientation,
|
||||
designatedTargetId: designatedTargetId);
|
||||
|
||||
bool collisionNormalValid = false;
|
||||
Vector3 collisionNormal = default;
|
||||
bool transitionOk = resolved.Ok;
|
||||
if (!resolved.Ok)
|
||||
{
|
||||
// UpdateObjectInternal 0x005156B0: when transition() returns null,
|
||||
// retail calls set_frame(candidate), zeros cached_velocity, and
|
||||
// deliberately skips SetPositionInternal/handle_all_collisions.
|
||||
// set_frame retains Position.objcell_id even when the candidate
|
||||
// frame lies beyond the current outdoor cell's canonical range.
|
||||
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
|
||||
body.SetFrameInCurrentCell(
|
||||
preparation.CandidatePosition,
|
||||
preparation.CandidateOrientation);
|
||||
body.CachedVelocity = Vector3.Zero;
|
||||
transitionOk = false;
|
||||
return;
|
||||
}
|
||||
|
||||
body.CachedVelocity = (resolved.Position - beginPosition) / dt;
|
||||
else
|
||||
{
|
||||
body.CachedVelocity = (resolved.Position - preparation.BeginPosition)
|
||||
/ preparation.Quantum;
|
||||
body.Position = resolved.Position;
|
||||
body.Orientation = resolved.Orientation == default
|
||||
? candidateOrientation
|
||||
? preparation.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,
|
||||
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 AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
|
|
@ -7,10 +8,10 @@ namespace AcDream.Core.Physics;
|
|||
/// by <c>CSequence::update</c> + InterpolationManager catch-up correction.
|
||||
/// Pure function — no side effects or hidden state.
|
||||
///
|
||||
/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730):
|
||||
/// rootOffset = CPartArray::Update(dt) // animation
|
||||
/// PositionManager::adjust_offset(rootOffset) // adds correction
|
||||
/// frame.origin += rootOffset
|
||||
/// Mirrors retail <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512C30):
|
||||
/// CPartArray writes one complete local Frame, then PositionManager mutates
|
||||
/// that same Frame. Active interpolation replaces it with
|
||||
/// <c>Position::subtract2</c>; later managers receive the result.
|
||||
///
|
||||
/// The animation root motion is the complete body-local <c>Frame.Origin</c>
|
||||
/// accumulated by <c>CPartArray::Update</c>: authored PosFrames plus the
|
||||
|
|
@ -30,6 +31,50 @@ namespace AcDream.Core.Physics;
|
|||
/// </summary>
|
||||
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>
|
||||
/// Compute the per-frame world-space delta to add to body.Position.
|
||||
/// </summary>
|
||||
|
|
@ -90,11 +135,21 @@ public sealed class RemoteMotionCombiner
|
|||
// AdjustOffset returns Vector3.Zero in two cases mapped to retail's
|
||||
// early-return: empty queue OR distance < DesiredDistance (0.05m).
|
||||
// In both, body falls back to animation root motion.
|
||||
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
|
||||
if (correction.LengthSquared() > 0f)
|
||||
return correction;
|
||||
|
||||
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocalDelta, ori);
|
||||
var root = new MotionDeltaFrame
|
||||
{
|
||||
Origin = rootMotionLocalDelta,
|
||||
};
|
||||
var output = new MotionDeltaFrame();
|
||||
ComposeOffset(
|
||||
dt,
|
||||
currentBodyPosition,
|
||||
ori,
|
||||
root,
|
||||
interp,
|
||||
maxSpeed,
|
||||
output,
|
||||
terrainNormal: null);
|
||||
Vector3 rootMotionWorld = Vector3.Transform(output.Origin, ori);
|
||||
|
||||
// Slope projection (queue-empty fallback only). Locomotion cycles
|
||||
// bake Z=0 in body-local, so without projection the body's Z stays
|
||||
|
|
|
|||
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>
|
||||
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(
|
||||
Action? checkDetection,
|
||||
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;
|
||||
controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput());
|
||||
MovementResult turning = controller.Update(1f / 60f, new MovementInput());
|
||||
// Rotation is authored/applied on an admitted retail object quantum,
|
||||
// not directly by the mouse sample. Stay below the outbound mouse
|
||||
// cadence while crossing the strict physics minimum.
|
||||
MovementResult turning = controller.Update(
|
||||
PhysicsBody.MinQuantum + 0.001f,
|
||||
new MovementInput());
|
||||
|
||||
Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand);
|
||||
Assert.Equal(0.8f, turning.TurnSpeed);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,10 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
|||
},
|
||||
() => calls.Add("spatial"));
|
||||
|
||||
live.Tick(1f / 60f);
|
||||
// CPhysicsObj::update_object admits manager time only after the strict
|
||||
// minimum quantum. Use one admitted object tick; the test's concern is
|
||||
// the network barrier, not render-fragment accumulation.
|
||||
live.Tick(PhysicsBody.MaxQuantum);
|
||||
Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
|
||||
|
||||
Assert.Equal(
|
||||
|
|
@ -150,6 +153,119 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
|||
Assert.Equal(controller.Position, positionSeenByTargetManager);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrozenObject_IsPresentedWithoutPhysicsInputOrOutboundState()
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
Vector3 initial = controller.Position;
|
||||
int inputCaptures = 0;
|
||||
int projections = 0;
|
||||
int preNetwork = 0;
|
||||
int postNetwork = 0;
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => true,
|
||||
getController: () => controller,
|
||||
captureInput: () =>
|
||||
{
|
||||
inputCaptures++;
|
||||
return new MovementInput(Forward: true);
|
||||
},
|
||||
resolveLocalEntityId: () => 7u,
|
||||
handleTargeting: () => throw new InvalidOperationException(
|
||||
"Frozen object advanced its manager tail."),
|
||||
isHidden: () => false,
|
||||
project: (_, _, _) => projections++,
|
||||
sendPreNetwork: (_, _, _) => preNetwork++,
|
||||
sendPostNetwork: (_, _) => postNetwork++,
|
||||
objectClockDisposition: () =>
|
||||
RetailObjectClockDisposition.Suspend);
|
||||
|
||||
local.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
|
||||
local.RunPostNetworkCommandPhase();
|
||||
Assert.True(local.TryGetPresentationAfterNetwork(out var presentation));
|
||||
|
||||
Assert.Equal(initial, controller.Position);
|
||||
Assert.Equal(PhysicsBody.MaxQuantum, controller.SimTimeSeconds);
|
||||
Assert.Equal(0, inputCaptures);
|
||||
Assert.Equal(2, projections);
|
||||
Assert.Equal(0, preNetwork);
|
||||
Assert.Equal(0, postNetwork);
|
||||
Assert.False(presentation.AdvancedBeforeNetwork);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HiddenPoseIsDirtyOnlyWhenACompleteObjectQuantumRuns()
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => true,
|
||||
getController: () => controller,
|
||||
captureInput: () => new MovementInput(),
|
||||
resolveLocalEntityId: () => 7u,
|
||||
handleTargeting: () => { },
|
||||
isHidden: () => true,
|
||||
project: (_, _, _) => { },
|
||||
sendPreNetwork: (_, _, _) => { },
|
||||
sendPostNetwork: (_, _) => { });
|
||||
|
||||
local.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
|
||||
Assert.True(local.HiddenPartPoseDirty);
|
||||
|
||||
local.AdvanceBeforeNetwork(PhysicsBody.MinQuantum / 2f);
|
||||
Assert.False(local.HiddenPartPoseDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidElapsed_PublishesSnapshotWithoutInputOrNetworkCallbacks()
|
||||
{
|
||||
PlayerMovementController controller = CreateController();
|
||||
int inputCaptures = 0;
|
||||
int targeting = 0;
|
||||
int projections = 0;
|
||||
int preNetwork = 0;
|
||||
int postNetwork = 0;
|
||||
var local = new RetailLocalPlayerFrameController(
|
||||
canPresentPlayer: () => true,
|
||||
getController: () => controller,
|
||||
captureInput: () =>
|
||||
{
|
||||
inputCaptures++;
|
||||
return new MovementInput(Forward: true);
|
||||
},
|
||||
resolveLocalEntityId: () => 7u,
|
||||
handleTargeting: () => targeting++,
|
||||
isHidden: () => false,
|
||||
project: (_, _, _) => projections++,
|
||||
sendPreNetwork: (_, _, _) => preNetwork++,
|
||||
sendPostNetwork: (_, _) => postNetwork++);
|
||||
float initialTime = controller.SimTimeSeconds;
|
||||
Vector3 initialPosition = controller.Position;
|
||||
|
||||
foreach (float elapsed in new[]
|
||||
{
|
||||
float.NaN,
|
||||
float.PositiveInfinity,
|
||||
float.NegativeInfinity,
|
||||
-1f,
|
||||
0f,
|
||||
})
|
||||
{
|
||||
local.AdvanceBeforeNetwork(elapsed);
|
||||
local.RunPostNetworkCommandPhase();
|
||||
Assert.True(local.TryGetPresentationAfterNetwork(out var frame));
|
||||
Assert.False(frame.AdvancedBeforeNetwork);
|
||||
Assert.False(local.HiddenPartPoseDirty);
|
||||
}
|
||||
|
||||
Assert.Equal(initialTime, controller.SimTimeSeconds);
|
||||
Assert.Equal(initialPosition, controller.Position);
|
||||
Assert.Equal(0, inputCaptures);
|
||||
Assert.Equal(0, targeting);
|
||||
Assert.Equal(0, preNetwork);
|
||||
Assert.Equal(0, postNetwork);
|
||||
Assert.Equal(10, projections);
|
||||
}
|
||||
|
||||
private static PlayerMovementController CreateController()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
|
|
@ -167,8 +283,10 @@ public sealed class RetailLocalPlayerFrameControllerTests
|
|||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
|
||||
var controller = new PlayerMovementController(engine);
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
var controller = new PlayerMovementController(engine, clock);
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
|
||||
Assert.True(clock.IsActive);
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
new Vector3(116.07f, 22.56f, 83.76f));
|
||||
|
||||
Assert.True(controller.TryGetOutboundPosition(
|
||||
Quaternion.Identity,
|
||||
out uint cellId,
|
||||
out Vector3 localOrigin));
|
||||
Assert.Equal(0xA9B20021u, cellId);
|
||||
Assert.Equal(new Vector3(116.07f, 22.56f, 83.76f), localOrigin);
|
||||
Assert.NotEqual(controller.Position, localOrigin);
|
||||
Assert.True(controller.TryGetOutboundPosition(out Position outbound));
|
||||
Assert.Equal(0xA9B20021u, outbound.ObjCellId);
|
||||
Assert.Equal(
|
||||
new Vector3(116.07f, 22.56f, 83.76f),
|
||||
outbound.Frame.Origin);
|
||||
Assert.NotEqual(controller.Position, outbound.Frame.Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -37,10 +36,30 @@ public sealed class PlayerOutboundPositionTests
|
|||
{
|
||||
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||
|
||||
Assert.False(controller.TryGetOutboundPosition(
|
||||
Quaternion.Identity,
|
||||
out _,
|
||||
out _));
|
||||
Assert.False(controller.TryGetOutboundPosition(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboundPosition_PreservesCompleteAuthoritativeQuaternion()
|
||||
{
|
||||
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||
controller.SetPosition(
|
||||
new Vector3(116.07f, 214.56f, 83.76f),
|
||||
0xA9B20021u,
|
||||
new Vector3(116.07f, 22.56f, 83.76f));
|
||||
Quaternion complete = Quaternion.Normalize(
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.7f)
|
||||
* Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.4f));
|
||||
controller.SetBodyOrientation(complete);
|
||||
|
||||
Assert.True(controller.TryGetOutboundPosition(out Position outbound));
|
||||
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
complete,
|
||||
Quaternion.Normalize(outbound.Frame.Orientation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -77,19 +77,19 @@ public sealed class ProjectileControllerTests
|
|||
| PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.IgnoreCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 1.1, 1, 1));
|
||||
record, record.FinalPhysicsState, 1.1, 1, 1));
|
||||
Vector3 hiddenPosition = entity.Position;
|
||||
fixture.Controller.Tick(5.0, 1, 1, playerWorldPosition: null);
|
||||
|
||||
Assert.Equal(hiddenPosition, entity.Position);
|
||||
Assert.True(body.InWorld);
|
||||
Assert.True(body.IsActive);
|
||||
Assert.Equal(5.0, body.LastUpdateTime, 8);
|
||||
Assert.Equal(0.0, record.ObjectClock.PendingSeconds, 8);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 5.0, 1, 1));
|
||||
record, record.FinalPhysicsState, 5.0, 1, 1));
|
||||
fixture.Controller.Tick(5.1, 1, 1, playerWorldPosition: null);
|
||||
|
||||
Assert.True(entity.Position.X > hiddenPosition.X);
|
||||
|
|
@ -116,7 +116,7 @@ public sealed class ProjectileControllerTests
|
|||
| PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.IgnoreCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 1.1, 1, 1));
|
||||
record, record.FinalPhysicsState, 1.1, 1, 1));
|
||||
remote.Interp.Enqueue(
|
||||
entity.Position + Vector3.UnitX,
|
||||
heading: 0f,
|
||||
|
|
@ -148,14 +148,27 @@ public sealed class ProjectileControllerTests
|
|||
var fixture = new Fixture(loadInitialLandblock: true);
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
fixture.Engine.ShadowObjects.Register(
|
||||
entity.Id,
|
||||
entity.SourceGfxObjOrSetupId,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
radius: 0.5f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
state: (uint)MissileState,
|
||||
seedCellId: CellA,
|
||||
isStatic: false);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
|
||||
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
Guid,
|
||||
worldPosition: new Vector3(202f, 10f, 5f),
|
||||
record,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(1f, 2f, 3f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -177,24 +190,36 @@ public sealed class ProjectileControllerTests
|
|||
Assert.False(runtime.Body.IsActive);
|
||||
|
||||
fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.True(runtime.Body.InWorld);
|
||||
Assert.True(runtime.Body.IsActive);
|
||||
Assert.Equal(pendingPosition, entity.Position);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
ShadowEntry restoredShadow = Assert.Single(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug());
|
||||
Assert.Equal(entity.Id, restoredShadow.EntityId);
|
||||
Assert.Equal(pendingPosition, restoredShadow.Position);
|
||||
|
||||
fixture.Controller.Tick(
|
||||
2.5,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1,
|
||||
playerWorldPosition: null);
|
||||
playerWorldPosition: pendingPosition);
|
||||
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.Same(entity, record.WorldEntity);
|
||||
Assert.Same(runtime, record.ProjectileRuntime);
|
||||
Assert.Equal(1, fixture.Resources.Registers);
|
||||
Assert.Equal(pendingPosition, entity.Position);
|
||||
Vector3 firstReentryTick = entity.Position;
|
||||
Assert.True(firstReentryTick.X > pendingPosition.X);
|
||||
|
||||
fixture.Controller.Tick(
|
||||
2.6,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1,
|
||||
playerWorldPosition: null);
|
||||
Assert.True(entity.Position.X > pendingPosition.X);
|
||||
playerWorldPosition: pendingPosition);
|
||||
Assert.True(entity.Position.X > firstReentryTick.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -229,7 +254,15 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
fixture.Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
Assert.Single(fixture.Live.SpatialProjectileRuntimes);
|
||||
fixture.Controller.Tick(1.1, 1, 1, playerWorldPosition: null);
|
||||
Assert.True(record.PhysicsBody.InWorld);
|
||||
Assert.True(record.PhysicsBody.IsActive);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
|
||||
fixture.Controller.Tick(
|
||||
1.1,
|
||||
1,
|
||||
1,
|
||||
playerWorldPosition: record.WorldEntity!.Position);
|
||||
|
||||
Assert.True(record.PhysicsBody.InWorld);
|
||||
Assert.True(record.PhysicsBody.IsActive);
|
||||
|
|
@ -304,9 +337,10 @@ public sealed class ProjectileControllerTests
|
|||
playerWorldPosition: null);
|
||||
|
||||
Assert.Equal(0xAAB40001u, record.FullCellId);
|
||||
Assert.Equal(0xAAB40001u, record.PhysicsBody!.CellPosition.ObjCellId);
|
||||
Assert.False(record.IsSpatiallyVisible);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.False(record.PhysicsBody!.InWorld);
|
||||
Assert.False(record.PhysicsBody.InWorld);
|
||||
Assert.False(record.PhysicsBody.IsActive);
|
||||
Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(startCell));
|
||||
Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u));
|
||||
|
|
@ -315,13 +349,14 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 0.06, 0xA9, 0xB4));
|
||||
record, record.FinalPhysicsState, 0.06, 0xA9, 0xB4));
|
||||
fixture.Spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu));
|
||||
fixture.Controller.Tick(0.07, 0xA9, 0xB4, playerWorldPosition: null);
|
||||
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.Equal(0xAAB40001u, record.PhysicsBody.CellPosition.ObjCellId);
|
||||
Assert.True(record.PhysicsBody.InWorld);
|
||||
Assert.False(record.PhysicsBody.IsActive);
|
||||
Assert.True(record.PhysicsBody.IsActive);
|
||||
Assert.Contains(
|
||||
fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u),
|
||||
entry => entry.EntityId == entity.Id);
|
||||
|
|
@ -336,7 +371,7 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsBody body = record.PhysicsBody!;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(7f, 8f, 9f),
|
||||
new Vector3(0f, 0f, 2f),
|
||||
currentTime: 4.5));
|
||||
|
|
@ -344,11 +379,13 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega);
|
||||
|
||||
var correction = new Vector3(30f, 31f, 32f);
|
||||
var correctedVelocity = new Vector3(3f, 4f, 5f);
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
Guid,
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 5.0,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -357,7 +394,95 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Same(body, record.PhysicsBody);
|
||||
Assert.Equal(correction, body.Position);
|
||||
Assert.Equal(correction, record.WorldEntity!.Position);
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
|
||||
// InboundPhysicsStateController normalizes an absent PositionPack
|
||||
// velocity to zero before this contract is called.
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 5.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuthoritativeMutation.Vector)]
|
||||
[InlineData(AuthoritativeMutation.Position)]
|
||||
[InlineData(AuthoritativeMutation.State)]
|
||||
public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction(
|
||||
AuthoritativeMutation mutation)
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
Assert.True(fixture.Controller.TryBeginQuantum(
|
||||
record,
|
||||
quantum: 0.05f,
|
||||
out ProjectileController.QuantumStep step));
|
||||
|
||||
Vector3 correctedPosition = new(30f, 31f, 32f);
|
||||
Vector3 correctedVelocity = new(7f, 8f, 9f);
|
||||
Vector3 correctedOmega = new(0f, 0f, 2f);
|
||||
PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity;
|
||||
switch (mutation)
|
||||
{
|
||||
case AuthoritativeMutation.Vector:
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
record,
|
||||
correctedVelocity,
|
||||
correctedOmega,
|
||||
currentTime: 1.02));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correctedPosition,
|
||||
correctedPosition,
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 1.02,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.State:
|
||||
record.FinalPhysicsState = correctedState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
record,
|
||||
correctedState,
|
||||
currentTime: 1.02,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(mutation));
|
||||
}
|
||||
|
||||
Assert.False(fixture.Controller.CompleteQuantum(step, 1, 1));
|
||||
switch (mutation)
|
||||
{
|
||||
case AuthoritativeMutation.Vector:
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
Assert.Equal(correctedOmega, body.Omega);
|
||||
break;
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.Equal(correctedPosition, body.Position);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
break;
|
||||
case AuthoritativeMutation.State:
|
||||
Assert.Equal(correctedState, body.State);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -392,7 +517,7 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsBody body = runtime.Body;
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid,
|
||||
record,
|
||||
PhysicsStateFlags.ReportCollisions,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -411,7 +536,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid,
|
||||
record,
|
||||
MissileState,
|
||||
currentTime: 1.3,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -452,7 +577,7 @@ public sealed class ProjectileControllerTests
|
|||
StateSequence: 2),
|
||||
out _));
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 0.06, 0xA9, 0xB4));
|
||||
record, record.FinalPhysicsState, 0.06, 0xA9, 0xB4));
|
||||
Assert.Equal(destinationCell, record.FullCellId);
|
||||
Assert.True(fixture.Live.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
|
|
@ -462,7 +587,7 @@ public sealed class ProjectileControllerTests
|
|||
StateSequence: 3),
|
||||
out _));
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 0.07, 0xA9, 0xB4));
|
||||
record, MissileState, 0.07, 0xA9, 0xB4));
|
||||
|
||||
Assert.Same(runtime, record.ProjectileRuntime);
|
||||
Assert.Same(runtime.Body, record.PhysicsBody);
|
||||
|
|
@ -481,7 +606,7 @@ public sealed class ProjectileControllerTests
|
|||
body.LastUpdateTime = 1.0;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(100f, 0f, 0f),
|
||||
Vector3.Zero,
|
||||
currentTime: 20.0));
|
||||
|
|
@ -506,7 +631,7 @@ public sealed class ProjectileControllerTests
|
|||
body.TransientState &= ~TransientStateFlags.Active;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid,
|
||||
record,
|
||||
MissileState | PhysicsStateFlags.Inelastic,
|
||||
currentTime: 2.0,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -516,11 +641,11 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 2.1, 1, 1));
|
||||
record, record.FinalPhysicsState, 2.1, 1, 1));
|
||||
Assert.False(body.IsActive);
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 2.2, 1, 1));
|
||||
record, MissileState, 2.2, 1, 1));
|
||||
Assert.False(body.IsActive);
|
||||
}
|
||||
|
||||
|
|
@ -533,10 +658,10 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsBody body = record.PhysicsBody!;
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 1.01, 1, 1));
|
||||
record, record.FinalPhysicsState, 1.01, 1, 1));
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(100f, 0f, 0f),
|
||||
new Vector3(0f, 0f, 3f),
|
||||
1.02));
|
||||
|
|
@ -561,11 +686,11 @@ public sealed class ProjectileControllerTests
|
|||
fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body));
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
record, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
Vector3 before = body.Velocity;
|
||||
|
||||
Assert.False(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(0f, 0f, 12f),
|
||||
new Vector3(0f, 0f, 2f),
|
||||
3.0));
|
||||
|
|
@ -586,14 +711,14 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
record, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
body.TransientState |= TransientStateFlags.Active;
|
||||
body.LastUpdateTime = 9.5; // generic remote interval used another clock owner
|
||||
body.set_velocity(new Vector3(40f, 0f, 0f));
|
||||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 10.0, 1, 1));
|
||||
record, MissileState, 10.0, 1, 1));
|
||||
Assert.Equal(10.0, body.LastUpdateTime, 8);
|
||||
Assert.True(body.IsActive);
|
||||
|
||||
|
|
@ -611,13 +736,13 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsBody body = record.PhysicsBody!;
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
record, record.FinalPhysicsState, 2.0, 1, 1));
|
||||
PhysicsStateFlags stateBefore = body.State;
|
||||
double clockBefore = body.LastUpdateTime;
|
||||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, double.NaN, 1, 1));
|
||||
record, MissileState, double.NaN, 1, 1));
|
||||
|
||||
Assert.Equal(record.FinalPhysicsState, body.State);
|
||||
Assert.NotEqual(stateBefore, body.State);
|
||||
|
|
@ -647,6 +772,41 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(entity.Position, after.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RootPoseCallbackGuidReuse_MakesQuantumReportSuperseded()
|
||||
{
|
||||
Fixture? fixture = null;
|
||||
LiveEntityRecord? first = null;
|
||||
LiveEntityRecord? replacement = null;
|
||||
bool replaced = false;
|
||||
fixture = new Fixture(
|
||||
publishRootPose: _ =>
|
||||
{
|
||||
if (replaced)
|
||||
return;
|
||||
replaced = true;
|
||||
Assert.NotNull(first);
|
||||
Assert.True(fixture!.Live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, first!.Generation),
|
||||
isLocalPlayer: false));
|
||||
replacement = fixture.Spawn(instance: 2);
|
||||
});
|
||||
first = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0, 1, 1));
|
||||
|
||||
Assert.False(fixture.Controller.AdvanceQuantum(
|
||||
first,
|
||||
quantum: 0.05f,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.True(replaced);
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out LiveEntityRecord current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PartArrayUsesRetailNinetySixUnitActivationBoundary()
|
||||
{
|
||||
|
|
@ -699,17 +859,18 @@ public sealed class ProjectileControllerTests
|
|||
Vector3 position = body.Position;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(float.NaN, 0f, 0f),
|
||||
Vector3.Zero,
|
||||
currentTime: 2.0));
|
||||
Assert.Equal(velocity, body.Velocity);
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
Guid,
|
||||
record,
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
|
|
@ -772,7 +933,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 2.0, 1, 1));
|
||||
record, MissileState, 2.0, 1, 1));
|
||||
Assert.NotNull(record.ProjectileRuntime);
|
||||
Assert.True(float.IsFinite(record.PhysicsBody!.Velocity.X));
|
||||
Assert.True(float.IsFinite(record.PhysicsBody.Velocity.Y));
|
||||
|
|
@ -793,7 +954,7 @@ public sealed class ProjectileControllerTests
|
|||
PhysicsStateFlags.Gravity | PhysicsStateFlags.IgnoreCollisions;
|
||||
record.FinalPhysicsState = ordinaryState;
|
||||
Assert.False(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, ordinaryState, 1.0, 1, 1));
|
||||
record, ordinaryState, 1.0, 1, 1));
|
||||
|
||||
Assert.Equal(ordinaryState, remote.Body.State);
|
||||
Assert.Null(record.ProjectileRuntime);
|
||||
|
|
@ -803,7 +964,7 @@ public sealed class ProjectileControllerTests
|
|||
ordinaryState | PhysicsStateFlags.Missile;
|
||||
record.FinalPhysicsState = unsupportedMissile;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, unsupportedMissile, 1.1, 1, 1));
|
||||
record, unsupportedMissile, 1.1, 1, 1));
|
||||
|
||||
Assert.Equal(unsupportedMissile, remote.Body.State);
|
||||
Assert.Null(record.ProjectileRuntime);
|
||||
|
|
@ -819,7 +980,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, double.NaN, 1, 1));
|
||||
record, MissileState, double.NaN, 1, 1));
|
||||
|
||||
Assert.NotNull(record.ProjectileRuntime);
|
||||
Assert.Equal(MissileState, record.PhysicsBody!.State);
|
||||
|
|
@ -827,7 +988,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 0.1, 1, 1));
|
||||
record, MissileState, 0.1, 1, 1));
|
||||
Assert.Same(runtime, record.ProjectileRuntime);
|
||||
}
|
||||
|
||||
|
|
@ -843,7 +1004,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, double.PositiveInfinity, 1, 1));
|
||||
record, MissileState, double.PositiveInfinity, 1, 1));
|
||||
|
||||
Assert.NotNull(record.ProjectileRuntime);
|
||||
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||
|
|
@ -922,7 +1083,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid,
|
||||
record,
|
||||
MissileState,
|
||||
currentTime: 1.2,
|
||||
liveCenterX: 0xA9,
|
||||
|
|
@ -941,7 +1102,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, record.FinalPhysicsState, 1.5, 0xA9, 0xB4));
|
||||
record, record.FinalPhysicsState, 1.5, 0xA9, 0xB4));
|
||||
Assert.Equal(destinationCell, remote.CellId);
|
||||
Assert.Equal(destinationCell, record.FullCellId);
|
||||
|
||||
|
|
@ -960,7 +1121,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 1.7, 0xA9, 0xB4));
|
||||
record, MissileState, 1.7, 0xA9, 0xB4));
|
||||
Assert.Equal(startCell, record.FullCellId);
|
||||
Assert.Equal(startCell, remote.CellId);
|
||||
Assert.Equal(startCell, body.CellPosition.ObjCellId);
|
||||
|
|
@ -986,7 +1147,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 1.0, 1, 1));
|
||||
record, MissileState, 1.0, 1, 1));
|
||||
|
||||
Assert.NotNull(record.ProjectileRuntime);
|
||||
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||
|
|
@ -1010,7 +1171,7 @@ public sealed class ProjectileControllerTests
|
|||
|
||||
record.FinalPhysicsState = MissileState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
Guid, MissileState, 1.0, 1, 1));
|
||||
record, MissileState, 1.0, 1, 1));
|
||||
|
||||
Assert.Null(record.ProjectileRuntime);
|
||||
Assert.Same(remote.Body, record.PhysicsBody);
|
||||
|
|
@ -1113,15 +1274,22 @@ public sealed class ProjectileControllerTests
|
|||
_ => throw new InvalidOperationException("re-entry recreated the entity")));
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
Guid,
|
||||
record,
|
||||
worldPosition: new Vector3(12f, 10f, 5f),
|
||||
cellLocalPosition: new Vector3(12f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: Vector3.Zero,
|
||||
fullCellId: CellA,
|
||||
currentTime: 10.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(10.0, record.PhysicsBody!.LastUpdateTime, 8);
|
||||
// The incarnation-stable RetailObjectQuantumClock is canonical after
|
||||
// the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute
|
||||
// clock mirror and need not equal the packet receipt time once the
|
||||
// visibility edge has already performed prepare_to_enter_world.
|
||||
Assert.Equal(0d, record.ObjectClock.PendingSeconds, 8);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(record.PhysicsBody!.IsActive);
|
||||
Assert.Contains(
|
||||
fixture.Engine.ShadowObjects.GetObjectsInCell(CellA),
|
||||
entry => entry.EntityId == entity.Id);
|
||||
|
|
@ -1130,8 +1298,14 @@ public sealed class ProjectileControllerTests
|
|||
10.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1,
|
||||
playerWorldPosition: null);
|
||||
Assert.Equal(13f, entity.Position.X, 3);
|
||||
playerWorldPosition: entity.Position);
|
||||
Assert.Equal(12f, entity.Position.X, 3);
|
||||
fixture.Controller.Tick(
|
||||
10.2,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1,
|
||||
playerWorldPosition: entity.Position);
|
||||
Assert.Equal(12f, entity.Position.X, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1148,14 +1322,94 @@ public sealed class ProjectileControllerTests
|
|||
isLocalPlayer: false));
|
||||
LiveEntityRecord second = fixture.Spawn(instance: 8);
|
||||
Assert.True(fixture.Controller.TryBind(second, ProjectileSetup(), 2.0));
|
||||
ILiveEntityProjectileRuntime secondRuntime = second.ProjectileRuntime!;
|
||||
|
||||
Assert.False(fixture.Controller.TryBind(first, ProjectileSetup(), 3.0));
|
||||
|
||||
Assert.NotSame(firstEntity, second.WorldEntity);
|
||||
Assert.NotSame(firstRuntime, second.ProjectileRuntime);
|
||||
Assert.Same(secondRuntime, second.ProjectileRuntime);
|
||||
Assert.Equal(1, fixture.Controller.Count);
|
||||
Assert.Equal(2, fixture.Resources.Registers);
|
||||
Assert.Equal(1, fixture.Resources.Unregisters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBind_ReentrantVisibilityGuidReuseNeverBindsOldBodyToReplacement()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord first = fixture.Spawn(
|
||||
instance: 7,
|
||||
cellId: CellA,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 202f, 5f));
|
||||
LiveEntityRecord? replacement = null;
|
||||
bool replaced = false;
|
||||
fixture.Live.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (replaced || visible || !ReferenceEquals(record, first))
|
||||
return;
|
||||
replaced = true;
|
||||
Assert.True(fixture.Live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 7),
|
||||
isLocalPlayer: false));
|
||||
replacement = fixture.Spawn(instance: 8);
|
||||
};
|
||||
|
||||
Assert.False(fixture.Controller.TryBind(
|
||||
first,
|
||||
ProjectileSetup(),
|
||||
currentTime: 1.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
Assert.Null(current.PhysicsBody);
|
||||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativePosition_ReentrantGuidReuseStopsOldPostRebucketWork()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord first = fixture.Spawn(instance: 7);
|
||||
Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0, 1, 1));
|
||||
LiveEntityRecord? replacement = null;
|
||||
bool replaced = false;
|
||||
fixture.Live.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (replaced || visible || !ReferenceEquals(record, first))
|
||||
return;
|
||||
replaced = true;
|
||||
Assert.True(fixture.Live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Guid, InstanceSequence: 7),
|
||||
isLocalPlayer: false));
|
||||
replacement = fixture.Spawn(instance: 8);
|
||||
};
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
first,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(44f, 0f, 0f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
Assert.Null(current.PhysicsBody);
|
||||
Assert.Equal(new Vector3(10f, 10f, 5f), current.WorldEntity!.Position);
|
||||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingRetailSphere_FailsWithoutFabricatingCollisionBody()
|
||||
{
|
||||
|
|
@ -1175,7 +1429,8 @@ public sealed class ProjectileControllerTests
|
|||
{
|
||||
internal Fixture(
|
||||
bool loadInitialLandblock = true,
|
||||
PhysicsEngine? physicsEngine = null)
|
||||
PhysicsEngine? physicsEngine = null,
|
||||
Action<WorldEntity>? publishRootPose = null)
|
||||
{
|
||||
if (loadInitialLandblock)
|
||||
Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
|
|
@ -1185,7 +1440,8 @@ public sealed class ProjectileControllerTests
|
|||
Live,
|
||||
Engine,
|
||||
_ => ResolvedSetup,
|
||||
entity => Poses.UpdateRoot(entity));
|
||||
publishRootPose ?? (entity => Poses.UpdateRoot(entity)),
|
||||
() => LiveCenter);
|
||||
}
|
||||
|
||||
internal GpuWorldState Spatial { get; } = new();
|
||||
|
|
@ -1194,6 +1450,7 @@ public sealed class ProjectileControllerTests
|
|||
internal PhysicsEngine Engine { get; }
|
||||
internal EntityEffectPoseRegistry Poses { get; } = new();
|
||||
internal Setup ResolvedSetup { get; set; } = ProjectileSetup();
|
||||
internal (int X, int Y) LiveCenter { get; set; } = (1, 1);
|
||||
internal ProjectileController Controller { get; }
|
||||
|
||||
internal LiveEntityRecord Spawn(
|
||||
|
|
@ -1221,6 +1478,7 @@ public sealed class ProjectileControllerTests
|
|||
elasticity);
|
||||
LiveEntityRegistrationResult registration = Live.RegisterLiveEntity(spawn);
|
||||
Assert.NotNull(registration.Record);
|
||||
registration.Record!.HasPartArray = true;
|
||||
WorldEntity? entity = Live.MaterializeLiveEntity(
|
||||
Guid,
|
||||
cellId,
|
||||
|
|
@ -1242,6 +1500,13 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
public enum AuthoritativeMutation
|
||||
{
|
||||
Vector,
|
||||
Position,
|
||||
State,
|
||||
}
|
||||
|
||||
private static Setup ProjectileSetup(float radius = 0.102f) => new()
|
||||
{
|
||||
Spheres =
|
||||
|
|
|
|||
|
|
@ -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.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
|
|
@ -14,6 +15,296 @@ namespace AcDream.App.Tests.Physics;
|
|||
|
||||
public sealed class RemotePhysicsUpdaterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShadowPoseGate_TracksTranslationAndSignInvariantOrientation()
|
||||
{
|
||||
Quaternion turn = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
|
||||
Assert.False(RemotePhysicsUpdater.ShouldSynchronizeShadowPose(
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity));
|
||||
Assert.False(RemotePhysicsUpdater.ShouldSynchronizeShadowPose(
|
||||
Vector3.Zero,
|
||||
turn,
|
||||
Vector3.Zero,
|
||||
new Quaternion(-turn.X, -turn.Y, -turn.Z, -turn.W)));
|
||||
Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadowPose(
|
||||
new Vector3(0.02f, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity));
|
||||
Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadowPose(
|
||||
Vector3.Zero,
|
||||
turn,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity));
|
||||
Assert.True(RemotePhysicsUpdater.ShouldSynchronizeShadow(
|
||||
cellChanged: true,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
Quaternion.Identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_InPlaceCompleteRootTurn_UpdatesOffsetCollisionShadow()
|
||||
{
|
||||
const uint cellId = 0x01010001u;
|
||||
Quaternion turn = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
var engine = new PhysicsEngine();
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 13u,
|
||||
ServerGuid = 0x80000013u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<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]
|
||||
public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics()
|
||||
{
|
||||
|
|
@ -74,15 +365,55 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
int partArrayTailCalls = 0;
|
||||
AnimationSequencer sequencer = CreateSequencer();
|
||||
sequencer.Manager.AddToQueue(MotionTableManager.ReadySentinel, 0);
|
||||
|
||||
updater.TickHidden(
|
||||
motion,
|
||||
entity,
|
||||
0.1f,
|
||||
() => partArrayTailCalls++);
|
||||
sequencer.Manager);
|
||||
|
||||
Assert.Equal(1, partArrayTailCalls);
|
||||
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickHidden_ProcessesPendingHooksBeforeManagerTail()
|
||||
{
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
new PhysicsEngine(),
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var motion = new GameWindow.RemoteMotion();
|
||||
motion.Body.Orientation = Quaternion.Identity;
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 3u,
|
||||
ServerGuid = 0x70000003u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<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]
|
||||
|
|
@ -133,7 +464,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void TickHidden_LandingSynchronizesRemoteAirborneState()
|
||||
public void TickHidden_OutOfContactDoesNotInterpolateOrInventLanding()
|
||||
{
|
||||
var engine = BuildBoundaryEngine();
|
||||
var updater = new RemotePhysicsUpdater(
|
||||
|
|
@ -165,9 +496,190 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
|
||||
updater.TickHidden(motion, entity, 0.1f);
|
||||
|
||||
Assert.True(motion.Body.InContact);
|
||||
Assert.True(motion.Body.OnWalkable);
|
||||
Assert.False(motion.Airborne);
|
||||
Assert.False(motion.Body.InContact);
|
||||
Assert.False(motion.Body.OnWalkable);
|
||||
Assert.True(motion.Airborne);
|
||||
Assert.Equal(new Vector3(10f, 10f, 50f), motion.Body.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_CrossCellIntoPending_CommitsRootAndSuspendsShadowBeforeReturning()
|
||||
{
|
||||
using var fixture = new BoundaryRemoteFixture();
|
||||
Vector3 sourceShadowPosition = fixture.Remote.LastShadowSyncPos;
|
||||
ulong clockEpoch = fixture.Record.ObjectClockEpoch;
|
||||
|
||||
bool current = fixture.Updater.Tick(
|
||||
fixture.Remote,
|
||||
fixture.Entity,
|
||||
objectScale: 1f,
|
||||
sequencer: null,
|
||||
animationForVelocityCycle: null,
|
||||
dt: 2f,
|
||||
rootMotionLocalFrame: new MotionDeltaFrame(),
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0,
|
||||
ownerRuntime: fixture.Live,
|
||||
ownerRecord: fixture.Record,
|
||||
ownerClockEpoch: clockEpoch);
|
||||
|
||||
Assert.False(current);
|
||||
Assert.Equal(BoundaryRemoteFixture.DestinationCell, fixture.Record.FullCellId);
|
||||
Assert.False(fixture.Record.IsSpatiallyVisible);
|
||||
Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position);
|
||||
Assert.Equal(BoundaryRemoteFixture.DestinationCell, fixture.Entity.ParentCellId);
|
||||
Assert.True(fixture.Entity.Position.X > 192f);
|
||||
Assert.Equal(sourceShadowPosition, fixture.Remote.LastShadowSyncPos);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
|
||||
fixture.HydrateDestination();
|
||||
|
||||
Assert.True(fixture.Record.IsSpatiallyVisible);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
ShadowEntry restored = Assert.Single(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug());
|
||||
Assert.Equal(fixture.Entity.Id, restored.EntityId);
|
||||
Assert.Equal(fixture.Entity.Position, restored.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeShadowPublisher_AlreadyPendingCannotResurrectShadow()
|
||||
{
|
||||
using var fixture = new BoundaryRemoteFixture();
|
||||
int publications = 0;
|
||||
|
||||
Assert.True(LiveEntityShadowPublisher.TryPublishRemote(
|
||||
fixture.Live,
|
||||
fixture.Record,
|
||||
fixture.Entity,
|
||||
fixture.Remote,
|
||||
fixture.Record.PositionAuthorityVersion,
|
||||
() => publications++));
|
||||
Assert.Equal(1, publications);
|
||||
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(
|
||||
BoundaryRemoteFixture.Guid,
|
||||
BoundaryRemoteFixture.DestinationCell));
|
||||
Assert.False(fixture.Record.IsSpatiallyVisible);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
|
||||
Assert.False(LiveEntityShadowPublisher.TryPublishRemote(
|
||||
fixture.Live,
|
||||
fixture.Record,
|
||||
fixture.Entity,
|
||||
fixture.Remote,
|
||||
fixture.Record.PositionAuthorityVersion,
|
||||
() => publications++));
|
||||
Assert.Equal(1, publications);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
|
||||
fixture.HydrateDestination();
|
||||
|
||||
Assert.True(fixture.Record.IsSpatiallyVisible);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeShadowPublisher_StaleAuthorityAndGuidReuseCannotPublish()
|
||||
{
|
||||
using var fixture = new BoundaryRemoteFixture();
|
||||
LiveEntityRecord oldRecord = fixture.Record;
|
||||
WorldEntity oldEntity = fixture.Entity;
|
||||
GameWindow.RemoteMotion oldRemote = fixture.Remote;
|
||||
ulong oldAuthority = oldRecord.PositionAuthorityVersion;
|
||||
int publications = 0;
|
||||
|
||||
Assert.False(LiveEntityShadowPublisher.TryPublishRemote(
|
||||
fixture.Live,
|
||||
oldRecord,
|
||||
oldEntity,
|
||||
oldRemote,
|
||||
oldAuthority + 1,
|
||||
() => publications++));
|
||||
|
||||
var replacement = fixture.ReplaceAtSource();
|
||||
|
||||
Assert.False(LiveEntityShadowPublisher.TryPublishRemote(
|
||||
fixture.Live,
|
||||
oldRecord,
|
||||
oldEntity,
|
||||
oldRemote,
|
||||
oldAuthority,
|
||||
() => publications++));
|
||||
Assert.Equal(0, publications);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Contains(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == replacement.Entity.Id);
|
||||
Assert.DoesNotContain(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == oldEntity.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_CrossCellVisibilityCallbackGuidReuse_CannotPublishOldShadow()
|
||||
{
|
||||
using var fixture = new BoundaryRemoteFixture();
|
||||
LiveEntityRecord oldRecord = fixture.Record;
|
||||
WorldEntity oldEntity = fixture.Entity;
|
||||
GameWindow.RemoteMotion oldRemote = fixture.Remote;
|
||||
WorldEntity? replacementEntity = null;
|
||||
GameWindow.RemoteMotion? replacementRemote = null;
|
||||
ulong clockEpoch = oldRecord.ObjectClockEpoch;
|
||||
|
||||
fixture.Live.ProjectionVisibilityChanged += (record, visible) =>
|
||||
{
|
||||
if (visible
|
||||
|| replacementEntity is not null
|
||||
|| !ReferenceEquals(record, oldRecord))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(LiveEntityRecord replacementRecord,
|
||||
replacementEntity,
|
||||
replacementRemote) = fixture.ReplaceAtSource();
|
||||
Assert.NotSame(oldRecord, replacementRecord);
|
||||
};
|
||||
|
||||
bool current = fixture.Updater.Tick(
|
||||
oldRemote,
|
||||
oldEntity,
|
||||
objectScale: 1f,
|
||||
sequencer: null,
|
||||
animationForVelocityCycle: null,
|
||||
dt: 2f,
|
||||
rootMotionLocalFrame: new MotionDeltaFrame(),
|
||||
liveCenterX: 0,
|
||||
liveCenterY: 0,
|
||||
ownerRuntime: fixture.Live,
|
||||
ownerRecord: oldRecord,
|
||||
ownerClockEpoch: clockEpoch);
|
||||
|
||||
Assert.False(current);
|
||||
Assert.NotNull(replacementEntity);
|
||||
Assert.NotNull(replacementRemote);
|
||||
Assert.True(fixture.Live.TryGetRecord(
|
||||
BoundaryRemoteFixture.Guid,
|
||||
out LiveEntityRecord currentRecord));
|
||||
Assert.NotSame(oldRecord, currentRecord);
|
||||
Assert.Same(replacementEntity, currentRecord.WorldEntity);
|
||||
Assert.Same(replacementRemote, currentRecord.RemoteMotionRuntime);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
|
||||
Assert.Equal(0, fixture.Engine.ShadowObjects.SuspendedRegistrationCount);
|
||||
Assert.Contains(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == replacementEntity.Id);
|
||||
Assert.DoesNotContain(
|
||||
fixture.Engine.ShadowObjects.AllEntriesForDebug(),
|
||||
entry => entry.EntityId == oldEntity.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -192,6 +704,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
(_, _) => (0.48f, 1.835f),
|
||||
(_, _, _, _) => { });
|
||||
var published = new List<uint>();
|
||||
var partPoseDirty = new List<uint>();
|
||||
updater.TickHiddenEntities(
|
||||
live,
|
||||
localPlayerServerGuid: 0x50000001u,
|
||||
|
|
@ -203,9 +716,11 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Assert.True(live.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(other, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
});
|
||||
},
|
||||
markPartPoseDirty: partPoseDirty.Add);
|
||||
|
||||
Assert.Single(published);
|
||||
Assert.Equal(published, partPoseDirty);
|
||||
Assert.Single(live.SpatialRemoteMotionRuntimes);
|
||||
}
|
||||
|
||||
|
|
@ -309,4 +824,208 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
currentBodyPosition: entity.Position);
|
||||
live.SetRemoteMotionRuntime(guid, remote);
|
||||
}
|
||||
|
||||
private sealed class BoundaryRemoteFixture : IDisposable
|
||||
{
|
||||
public const uint Guid = 0x70000071u;
|
||||
public const uint SourceCell = 0xA9B40039u;
|
||||
public const uint DestinationCell = 0xAAB40001u;
|
||||
|
||||
private LiveEntityPresentationController? _presentation;
|
||||
|
||||
public BoundaryRemoteFixture()
|
||||
{
|
||||
Engine = BuildBoundaryEngine();
|
||||
Spatial.AddLandblock(new LoadedLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<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 double LastServerPositionTime { get; set; }
|
||||
public Vector3 LastShadowSyncPosition { get; set; }
|
||||
public Quaternion LastShadowSyncOrientation { get; set; }
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
||||
{
|
||||
_readCell = read;
|
||||
|
|
@ -64,6 +65,7 @@ public sealed class RemoteTeleportControllerTests
|
|||
public Vector3 LastServerPosition { get; set; }
|
||||
public double LastServerPositionTime { get; set; }
|
||||
public Vector3 LastShadowSyncPosition { get; set; }
|
||||
public Quaternion LastShadowSyncOrientation { get; set; }
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write) { }
|
||||
public void HitGround() { }
|
||||
public void LeaveGround() { }
|
||||
|
|
@ -921,6 +923,165 @@ public sealed class RemoteTeleportControllerTests
|
|||
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolverAcceptsNewerPosition_OlderPlacementReturnsSuperseded()
|
||||
{
|
||||
const uint guid = 0x70000060u;
|
||||
const uint sourceCell = 0xA9B40039u;
|
||||
const uint destinationCell = 0xAAB40001u;
|
||||
var fixture = CreateLoadedRemoteFixture(guid, sourceCell);
|
||||
Assert.True(fixture.Live.TryApplyPosition(
|
||||
PositionUpdate(guid, destinationCell, x: 1f, sequence: 2),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(guid, destinationCell));
|
||||
Assert.True(fixture.Live.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
ulong outerPositionAuthority = record.PositionAuthorityVersion;
|
||||
ulong outerVelocityAuthority = record.VelocityAuthorityVersion;
|
||||
Vector3 newerWorldPosition = new(195f, 10f, 50f);
|
||||
RemoteTeleportController? controller = null;
|
||||
RemoteTeleportController.Result nestedResult = default;
|
||||
bool nested = false;
|
||||
controller = new RemoteTeleportController(
|
||||
fixture.Engine,
|
||||
fixture.Live,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
CellLocal,
|
||||
(_, _, _) => { },
|
||||
(_, _, _) => { },
|
||||
(_, _) => { },
|
||||
resolvePlacement: (position, cell, _, _, _, _) =>
|
||||
{
|
||||
if (!nested)
|
||||
{
|
||||
nested = true;
|
||||
Assert.True(fixture.Live.TryApplyPosition(
|
||||
PositionUpdate(guid, destinationCell, x: 3f, sequence: 3),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(fixture.Live.TryGetRecord(guid, out var current));
|
||||
nestedResult = controller!.TryApply(
|
||||
current,
|
||||
current.PositionAuthorityVersion,
|
||||
current.VelocityAuthorityVersion,
|
||||
fixture.Remote,
|
||||
fixture.Entity,
|
||||
newerWorldPosition,
|
||||
destinationCell,
|
||||
new Vector3(3f, 10f, 50f),
|
||||
Quaternion.Identity,
|
||||
gameTime: 6.0,
|
||||
destinationProjectionVisible: true,
|
||||
generation: 1,
|
||||
positionSequence: 3);
|
||||
}
|
||||
|
||||
return SuccessfulPlacement(position, cell);
|
||||
});
|
||||
|
||||
RemoteTeleportController.Result outerResult = controller.TryApply(
|
||||
record,
|
||||
outerPositionAuthority,
|
||||
outerVelocityAuthority,
|
||||
fixture.Remote,
|
||||
fixture.Entity,
|
||||
new Vector3(193f, 10f, 50f),
|
||||
destinationCell,
|
||||
new Vector3(1f, 10f, 50f),
|
||||
Quaternion.Identity,
|
||||
gameTime: 5.0,
|
||||
destinationProjectionVisible: true,
|
||||
generation: 1,
|
||||
positionSequence: 2);
|
||||
|
||||
Assert.True(nestedResult.Applied);
|
||||
Assert.True(nestedResult.ContactResolved);
|
||||
Assert.True(outerResult.Superseded);
|
||||
Assert.False(outerResult.Applied);
|
||||
Assert.Equal(newerWorldPosition, fixture.Remote.Body.Position);
|
||||
Assert.Equal(newerWorldPosition, fixture.Entity.Position);
|
||||
controller.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolverAcceptsIndependentState_PositionPlacementStillCommits()
|
||||
{
|
||||
const uint guid = 0x70000061u;
|
||||
const uint sourceCell = 0xA9B40039u;
|
||||
const uint destinationCell = 0xAAB40001u;
|
||||
var fixture = CreateLoadedRemoteFixture(guid, sourceCell);
|
||||
Assert.True(fixture.Live.TryApplyPosition(
|
||||
PositionUpdate(guid, destinationCell, x: 1f, sequence: 2),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(guid, destinationCell));
|
||||
Assert.True(fixture.Live.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
ulong positionAuthority = record.PositionAuthorityVersion;
|
||||
ulong velocityAuthority = record.VelocityAuthorityVersion;
|
||||
bool stateAccepted = false;
|
||||
using var controller = new RemoteTeleportController(
|
||||
fixture.Engine,
|
||||
fixture.Live,
|
||||
(_, _) => (0.48f, 1.835f),
|
||||
CellLocal,
|
||||
(_, _, _) => { },
|
||||
(_, _, _) => { },
|
||||
(_, _) => { },
|
||||
resolvePlacement: (position, cell, _, _, _, _) =>
|
||||
{
|
||||
if (!stateAccepted)
|
||||
{
|
||||
stateAccepted = true;
|
||||
Assert.True(fixture.Live.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
guid,
|
||||
(uint)(PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.ReportCollisions),
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
}
|
||||
return SuccessfulPlacement(position, cell);
|
||||
});
|
||||
Vector3 destination = new(193f, 10f, 50f);
|
||||
|
||||
RemoteTeleportController.Result result = controller.TryApply(
|
||||
record,
|
||||
positionAuthority,
|
||||
velocityAuthority,
|
||||
fixture.Remote,
|
||||
fixture.Entity,
|
||||
destination,
|
||||
destinationCell,
|
||||
new Vector3(1f, 10f, 50f),
|
||||
Quaternion.Identity,
|
||||
gameTime: 5.0,
|
||||
destinationProjectionVisible: true,
|
||||
generation: 1,
|
||||
positionSequence: 2);
|
||||
|
||||
Assert.True(stateAccepted);
|
||||
Assert.True(result.Applied);
|
||||
Assert.True(result.ContactResolved);
|
||||
Assert.False(result.Superseded);
|
||||
Assert.Equal(destination, fixture.Remote.Body.Position);
|
||||
Assert.Equal(destination, fixture.Entity.Position);
|
||||
Assert.True(record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden));
|
||||
Assert.Equal(record.FinalPhysicsState, fixture.Remote.Body.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeardownCallbackFailure_StillForgetsPendingBeforeGuidReuse()
|
||||
{
|
||||
|
|
@ -1009,6 +1170,75 @@ public sealed class RemoteTeleportControllerTests
|
|||
return engine;
|
||||
}
|
||||
|
||||
private static LoadedRemoteFixture CreateLoadedRemoteFixture(
|
||||
uint guid,
|
||||
uint sourceCell)
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu));
|
||||
var live = new LiveEntityRuntime(spatial, new Resources());
|
||||
live.RegisterLiveEntity(Spawn(guid, sourceCell));
|
||||
WorldEntity entity = live.MaterializeLiveEntity(
|
||||
guid,
|
||||
sourceCell,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(191f, 10f, 50f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<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) =>
|
||||
engine.AddLandblock(
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void AnimationHookQueue_CompletesMotionStateAtCaptureEvenWhenPoseWasRemoved()
|
||||
{
|
||||
|
|
@ -217,6 +354,17 @@ public sealed class EntityEffectPoseRegistryTests
|
|||
Calls.Add((entityId, entityWorldPosition));
|
||||
}
|
||||
|
||||
private sealed class CallbackSink(Action callback) : IAnimationHookSink
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
Calls++;
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint animationId) => null;
|
||||
|
|
|
|||
|
|
@ -260,6 +260,193 @@ public sealed class EntityScriptActivatorTests
|
|||
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatStaticAnimationOwner_RegistersAndUnregistersExactlyOnce()
|
||||
{
|
||||
var p = BuildPipeline();
|
||||
int registerCount = 0;
|
||||
int unregisterCount = 0;
|
||||
WorldEntity? registeredEntity = null;
|
||||
ScriptActivationInfo info = new(
|
||||
ScriptId: 0,
|
||||
PartTransforms: Array.Empty<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]
|
||||
public void CollidingDatEntityIds_FailFastAtAllocatorBoundary()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -797,6 +797,7 @@ public sealed class LiveEntityLifecycleStressTests
|
|||
public Vector3 LastServerPosition { get; set; }
|
||||
public double LastServerPositionTime { get; set; }
|
||||
public Vector3 LastShadowSyncPosition { get; set; }
|
||||
public Quaternion LastShadowSyncOrientation { get; set; }
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
||||
{
|
||||
_readCell = read;
|
||||
|
|
|
|||
|
|
@ -164,6 +164,113 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
Assert.Equal([fixture.Entity.Id], fixture.PartArrayEnterWorld);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateAcceptedDuringHiddenEffect_DrainsAfterCompleteHiddenTail()
|
||||
{
|
||||
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
bool nestedAccepted = false;
|
||||
fixture.OnTypedPlay = type =>
|
||||
{
|
||||
if (type != LiveEntityPresentationController.HiddenScriptType
|
||||
|| nestedAccepted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nestedAccepted = true;
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Fixture.Guid,
|
||||
(uint)PhysicsStateFlags.ReportCollisions,
|
||||
1,
|
||||
3),
|
||||
out _));
|
||||
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
||||
};
|
||||
|
||||
Assert.True(fixture.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Fixture.Guid,
|
||||
(uint)(PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.ReportCollisions),
|
||||
1,
|
||||
2),
|
||||
out _));
|
||||
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
||||
|
||||
Assert.True(nestedAccepted);
|
||||
Assert.Equal(
|
||||
[
|
||||
"effect:76", "children:True", "part-array", "target",
|
||||
"effect:75", "children:False", "part-array",
|
||||
],
|
||||
fixture.PresentationOrder);
|
||||
Assert.True(fixture.Entity.IsDrawVisible);
|
||||
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteAndGuidReuseDuringHiddenEffect_StopsOldTransitionTail()
|
||||
{
|
||||
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
Assert.True(fixture.Runtime.TryGetRecord(
|
||||
Fixture.Guid,
|
||||
out LiveEntityRecord oldRecord));
|
||||
WorldEntity? replacement = null;
|
||||
fixture.OnTypedPlay = type =>
|
||||
{
|
||||
if (type != LiveEntityPresentationController.HiddenScriptType
|
||||
|| replacement is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
fixture.Controller.Forget(oldRecord);
|
||||
fixture.Shadows.Deregister(fixture.Entity.Id);
|
||||
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Fixture.Guid, 1),
|
||||
isLocalPlayer: false));
|
||||
fixture.Runtime.RegisterLiveEntity(Fixture.Spawn(
|
||||
PhysicsStateFlags.ReportCollisions,
|
||||
instanceSequence: 2));
|
||||
replacement = fixture.Runtime.MaterializeLiveEntity(
|
||||
Fixture.Guid,
|
||||
0x01010001u,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = Fixture.Guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = new Vector3(20f, 20f, 5f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<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]
|
||||
public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns()
|
||||
{
|
||||
|
|
@ -200,6 +307,285 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibleOrdinaryProjectionPending_SuspendsAndHydrationRestoresShadow()
|
||||
{
|
||||
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||
|
||||
Assert.True(fixture.Runtime.RebucketLiveEntity(
|
||||
Fixture.Guid,
|
||||
0x02020001u));
|
||||
|
||||
Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
|
||||
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
||||
Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount);
|
||||
Assert.Equal(1, fixture.Shadows.SuspendedRegistrationCount);
|
||||
Assert.True(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||
|
||||
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0202FFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<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]
|
||||
public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset()
|
||||
{
|
||||
|
|
@ -260,6 +646,7 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
public ShadowObjectRegistry Shadows { get; } = new();
|
||||
public WorldEntity Entity { get; }
|
||||
public LiveEntityPresentationController Controller { get; }
|
||||
public Action<uint>? OnTypedPlay { get; set; }
|
||||
|
||||
public Fixture(
|
||||
PhysicsStateFlags initialState,
|
||||
|
|
@ -306,6 +693,7 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
{
|
||||
TypedPlays.Add((owner, type, intensity));
|
||||
PresentationOrder.Add($"effect:{type:X2}");
|
||||
OnTypedPlay?.Invoke(type);
|
||||
return true;
|
||||
},
|
||||
(parent, noDraw) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Net;
|
||||
|
|
@ -189,6 +191,195 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Equal(new[] { false, true }, visibilityEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticRootCommit_HiddenObjectUpdatesPoseWithoutRestoringShadow()
|
||||
{
|
||||
const uint guid = 0x70000071u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(
|
||||
guid,
|
||||
instance: 1,
|
||||
positionSequence: 1,
|
||||
cell: 0x01010001u,
|
||||
state: PhysicsStateFlags.Static | PhysicsStateFlags.ReportCollisions);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
entity.Position = new Vector3(12f, 12f, 5f);
|
||||
PhysicsBody body = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
var created = new PhysicsBody();
|
||||
created.SnapToCell(
|
||||
0x01010001u,
|
||||
entity.Position,
|
||||
new Vector3(12f, 12f, 5f));
|
||||
return created;
|
||||
});
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
shadows.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
new[]
|
||||
{
|
||||
new ShadowShape(
|
||||
0x01000001u,
|
||||
Vector3.UnitX,
|
||||
Quaternion.Identity,
|
||||
Scale: 1f,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: 0.5f,
|
||||
CylHeight: 1f),
|
||||
},
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
flags: EntityCollisionFlags.None,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
seedCellId: 0x01010001u);
|
||||
int poseUpdates = 0;
|
||||
var committer = new StaticLiveRootCommitter(
|
||||
() => runtime,
|
||||
shadows,
|
||||
() => (1, 1),
|
||||
_ => poseUpdates++);
|
||||
|
||||
body.SetFrameInCurrentCell(
|
||||
body.Position,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
|
||||
entity.Rotation = body.Orientation;
|
||||
Assert.True(committer.Commit(entity, body));
|
||||
ShadowEntry visible = Assert.Single(shadows.AllEntriesForDebug());
|
||||
Assert.InRange(visible.Position.X, 11.999f, 12.001f);
|
||||
Assert.InRange(visible.Position.Y, 12.999f, 13.001f);
|
||||
|
||||
Assert.True(runtime.TryApplyState(new SetState.Parsed(
|
||||
guid,
|
||||
(uint)(PhysicsStateFlags.Static
|
||||
| PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.IgnoreCollisions),
|
||||
InstanceSequence: 1,
|
||||
StateSequence: 2), out _));
|
||||
Assert.True(shadows.Suspend(entity.Id));
|
||||
body.SetFrameInCurrentCell(
|
||||
body.Position,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI));
|
||||
entity.Rotation = body.Orientation;
|
||||
|
||||
Assert.True(committer.Commit(entity, body));
|
||||
Assert.Equal(2, poseUpdates);
|
||||
Assert.Empty(shadows.AllEntriesForDebug());
|
||||
Assert.Equal(1, shadows.SuspendedRegistrationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticRootCommit_BeforeLiveRuntimeCompositionIsSafeNoOp()
|
||||
{
|
||||
LiveEntityRuntime? runtime = null;
|
||||
int poseUpdates = 0;
|
||||
var committer = new StaticLiveRootCommitter(
|
||||
() => runtime,
|
||||
new ShadowObjectRegistry(),
|
||||
() => (0, 0),
|
||||
_ => poseUpdates++);
|
||||
|
||||
Assert.False(committer.Commit(
|
||||
Entity(0x7F000001u, 0x70000001u),
|
||||
new PhysicsBody()));
|
||||
Assert.Equal(0, poseUpdates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticRootCommit_EffectCallbackReplacesGuid_DoesNotRestoreOldShadow()
|
||||
{
|
||||
const uint guid = 0x70000074u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(
|
||||
guid,
|
||||
instance: 1,
|
||||
positionSequence: 1,
|
||||
cell: 0x01010001u,
|
||||
state: PhysicsStateFlags.Static | PhysicsStateFlags.ReportCollisions));
|
||||
WorldEntity oldEntity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
oldEntity.Position = new Vector3(12f, 12f, 5f);
|
||||
PhysicsBody oldBody = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
var created = new PhysicsBody();
|
||||
created.SnapToCell(
|
||||
0x01010001u,
|
||||
oldEntity.Position,
|
||||
oldEntity.Position);
|
||||
return created;
|
||||
});
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
shadows.RegisterMultiPart(
|
||||
oldEntity.Id,
|
||||
oldEntity.Position,
|
||||
oldEntity.Rotation,
|
||||
new[]
|
||||
{
|
||||
new ShadowShape(
|
||||
0x01000001u,
|
||||
Vector3.UnitX,
|
||||
Quaternion.Identity,
|
||||
Scale: 1f,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: 0.5f,
|
||||
CylHeight: 1f),
|
||||
},
|
||||
state: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
flags: EntityCollisionFlags.None,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0x01010000u,
|
||||
seedCellId: 0x01010001u);
|
||||
var committer = new StaticLiveRootCommitter(
|
||||
() => runtime,
|
||||
shadows,
|
||||
() => (1, 1),
|
||||
_ =>
|
||||
{
|
||||
Assert.True(shadows.Suspend(oldEntity.Id));
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(guid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
runtime.RegisterLiveEntity(Spawn(
|
||||
guid,
|
||||
instance: 2,
|
||||
positionSequence: 1,
|
||||
cell: 0x01010001u));
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid));
|
||||
});
|
||||
|
||||
oldBody.SetFrameInCurrentCell(
|
||||
oldBody.Position,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
|
||||
oldEntity.Rotation = oldBody.Orientation;
|
||||
|
||||
Assert.False(committer.Commit(oldEntity, oldBody));
|
||||
Assert.Empty(shadows.AllEntriesForDebug());
|
||||
Assert.Equal(1, shadows.SuspendedRegistrationCount);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
|
||||
Assert.Equal((ushort)2, replacement.Generation);
|
||||
Assert.NotSame(oldEntity, replacement.WorldEntity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpatialComponentWorksets_FollowLoadedProjectionWhileLogicalOwnersSurvive()
|
||||
{
|
||||
|
|
@ -313,6 +504,41 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Equal(2, runtime.AnimationRuntimes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnimationView_HotSpatialTraversalDoesNotAllocateAfterWarmup()
|
||||
{
|
||||
const uint guid = 0x70000075u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
|
||||
var view = new LiveEntityAnimationRuntimeView<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]
|
||||
public void SpatialComponentWorksets_IsolateGuidReuseAcrossVisibilityCallbacks()
|
||||
{
|
||||
|
|
@ -350,6 +576,60 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.NotSame(oldAnimation, indexed.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPhysicsBody_IsCreatedOncePerIncarnationAndLaterReused()
|
||||
{
|
||||
const uint guid = 0x70000073u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid));
|
||||
int factoryCalls = 0;
|
||||
|
||||
PhysicsBody first = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
factoryCalls++;
|
||||
return new PhysicsBody();
|
||||
});
|
||||
PhysicsBody retained = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
factoryCalls++;
|
||||
return new PhysicsBody();
|
||||
});
|
||||
|
||||
Assert.Same(first, retained);
|
||||
Assert.Equal(1, factoryCalls);
|
||||
Assert.True(runtime.TryGetRecord(guid, out var firstRecord));
|
||||
Assert.Same(first, firstRecord.PhysicsBody);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(guid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u));
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid));
|
||||
PhysicsBody replacement = runtime.GetOrCreatePhysicsBody(
|
||||
guid,
|
||||
_ =>
|
||||
{
|
||||
factoryCalls++;
|
||||
return new PhysicsBody();
|
||||
});
|
||||
|
||||
Assert.NotSame(first, replacement);
|
||||
Assert.Equal(2, factoryCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown()
|
||||
{
|
||||
|
|
@ -667,15 +947,24 @@ public sealed class LiveEntityRuntimeTests
|
|||
id => Entity(id, guid))!;
|
||||
var animation = new AnimationRuntime(entity);
|
||||
runtime.SetAnimationRuntime(guid, animation);
|
||||
var remote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(guid, remote);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
ulong initialClockEpoch = record.ObjectClockEpoch;
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(remote.Body.IsActive);
|
||||
uint localId = entity.Id;
|
||||
Assert.True(runtime.TryMarkWorldSpawnPublished(guid));
|
||||
|
||||
Assert.True(runtime.TryApplyPickup(
|
||||
new PickupEvent.Parsed(guid, spawn.InstanceSequence, 11),
|
||||
out _));
|
||||
Assert.False(record.ObjectClock.IsActive);
|
||||
Assert.False(remote.Body.IsActive);
|
||||
Assert.Equal(initialClockEpoch + 1, record.ObjectClockEpoch);
|
||||
Assert.True(runtime.WithdrawLiveEntityProjection(guid));
|
||||
Assert.Equal(initialClockEpoch + 1, record.ObjectClockEpoch);
|
||||
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
Assert.Equal(0u, record.FullCellId);
|
||||
Assert.Equal(0u, record.CanonicalLandblockId);
|
||||
Assert.False(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
|
|
@ -711,6 +1000,9 @@ public sealed class LiveEntityRuntimeTests
|
|||
|
||||
Assert.Same(entity, same);
|
||||
Assert.Equal(localId, same.Id);
|
||||
Assert.True(record.ObjectClock.IsActive);
|
||||
Assert.True(remote.Body.IsActive);
|
||||
Assert.Equal(initialClockEpoch + 2, record.ObjectClockEpoch);
|
||||
Assert.False(runtime.TryMarkWorldSpawnPublished(guid));
|
||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
Assert.Equal(0x01010002u, record.FullCellId);
|
||||
|
|
@ -760,6 +1052,267 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectClock_SurvivesLoadedRebucketAndRebasesPendingReentry()
|
||||
{
|
||||
const uint guid = 0x70000047u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!;
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
RetailObjectQuantumClock clock = record.ObjectClock;
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u));
|
||||
Assert.Same(clock, record.ObjectClock);
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.02, clock.PendingSeconds, 8);
|
||||
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
|
||||
Assert.False(clock.IsActive);
|
||||
Assert.Equal(0.02, clock.PendingSeconds, 8);
|
||||
spatial.AddLandblock(EmptyLandblock(0x0202FFFFu));
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
|
||||
Assert.Equal(
|
||||
RetailObjectActivityResult.Active,
|
||||
RetailObjectActivityGate.Evaluate(
|
||||
clock,
|
||||
body: null,
|
||||
lifecycleEligible: runtime.ShouldAdvanceRootRuntime(guid),
|
||||
hasPartArray: true,
|
||||
isStatic: false,
|
||||
objectPosition: Vector3.Zero,
|
||||
playerPosition: Vector3.Zero,
|
||||
elapsedSeconds: 0.1));
|
||||
Assert.Same(clock, record.ObjectClock);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitiallyVisibleStaticObject_RebasesWithoutBecomingActive()
|
||||
{
|
||||
const uint guid = 0x70000049u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(
|
||||
guid,
|
||||
1,
|
||||
1,
|
||||
0x01010001u,
|
||||
PhysicsStateFlags.Static);
|
||||
LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!;
|
||||
var remote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(guid, remote);
|
||||
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
|
||||
Assert.False(record.ObjectClock.IsActive);
|
||||
Assert.Equal(0.0, record.ObjectClock.PendingSeconds, 8);
|
||||
Assert.False(remote.Body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovementActivation_ReactivatesExactIncarnation_ButStaticIsNoOp()
|
||||
{
|
||||
const uint ordinaryGuid = 0x70000050u;
|
||||
const uint staticGuid = 0x70000051u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
|
||||
LiveEntityRecord ordinary = runtime.RegisterLiveEntity(
|
||||
Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!;
|
||||
var ordinaryRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
ordinaryGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, ordinaryGuid));
|
||||
ordinary.ObjectClock.Deactivate();
|
||||
ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active;
|
||||
|
||||
Assert.True(runtime.TryActivateOrdinaryObject(ordinaryGuid, ordinaryRemote));
|
||||
Assert.True(ordinary.ObjectClock.IsActive);
|
||||
Assert.True(ordinaryRemote.Body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
|
||||
LiveEntityRecord staticRecord = runtime.RegisterLiveEntity(
|
||||
Spawn(staticGuid, 1, 1, 0x01010001u, PhysicsStateFlags.Static)).Record!;
|
||||
var staticRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(staticGuid, staticRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
staticGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, staticGuid));
|
||||
|
||||
Assert.False(runtime.TryActivateOrdinaryObject(staticGuid, staticRemote));
|
||||
Assert.False(staticRecord.ObjectClock.IsActive);
|
||||
Assert.False(staticRemote.Body.TransientState.HasFlag(TransientStateFlags.Active));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeVector_WakesOrdinaryClockButPreservesStaticActivity()
|
||||
{
|
||||
const uint ordinaryGuid = 0x70000052u;
|
||||
const uint defensiveGuid = 0x70000053u;
|
||||
const uint staticGuid = 0x70000054u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
|
||||
LiveEntityRecord ordinary = runtime.RegisterLiveEntity(
|
||||
Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!;
|
||||
var ordinaryRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
ordinaryGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, ordinaryGuid));
|
||||
ordinary.ObjectClock.Deactivate();
|
||||
ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active;
|
||||
ordinaryRemote.Body.LastUpdateTime = 1.0;
|
||||
|
||||
Assert.True(runtime.TryCommitAuthoritativeVector(
|
||||
ordinary,
|
||||
ordinaryRemote.Body,
|
||||
new Vector3(1f, 2f, 3f),
|
||||
new Vector3(4f, 5f, 6f),
|
||||
currentTime: 8.0));
|
||||
Assert.True(ordinary.ObjectClock.IsActive);
|
||||
Assert.True(ordinaryRemote.Body.IsActive);
|
||||
Assert.Equal(8.0, ordinaryRemote.Body.LastUpdateTime);
|
||||
Assert.Equal(new Vector3(1f, 2f, 3f), ordinaryRemote.Body.Velocity);
|
||||
Assert.Equal(new Vector3(4f, 5f, 6f), ordinaryRemote.Body.Omega);
|
||||
|
||||
// Defensive split state: the retained object clock is already active,
|
||||
// but a body consumer cleared its legacy Active bit. set_velocity must
|
||||
// still rebase that body's compatibility timestamp.
|
||||
LiveEntityRecord defensive = runtime.RegisterLiveEntity(
|
||||
Spawn(defensiveGuid, 1, 1, 0x01010001u)).Record!;
|
||||
var defensiveRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(defensiveGuid, defensiveRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
defensiveGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, defensiveGuid));
|
||||
Assert.True(defensive.ObjectClock.IsActive);
|
||||
defensiveRemote.Body.TransientState &= ~TransientStateFlags.Active;
|
||||
defensiveRemote.Body.LastUpdateTime = 2.0;
|
||||
|
||||
Assert.True(runtime.TryCommitAuthoritativeVelocity(
|
||||
defensive,
|
||||
defensiveRemote.Body,
|
||||
Vector3.UnitX,
|
||||
currentTime: 9.0));
|
||||
Assert.True(defensive.ObjectClock.IsActive);
|
||||
Assert.True(defensiveRemote.Body.IsActive);
|
||||
Assert.Equal(9.0, defensiveRemote.Body.LastUpdateTime);
|
||||
|
||||
LiveEntityRecord staticRecord = runtime.RegisterLiveEntity(
|
||||
Spawn(
|
||||
staticGuid,
|
||||
1,
|
||||
1,
|
||||
0x01010001u,
|
||||
PhysicsStateFlags.Static)).Record!;
|
||||
var staticRemote = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(staticGuid, staticRemote);
|
||||
runtime.MaterializeLiveEntity(
|
||||
staticGuid,
|
||||
0x01010001u,
|
||||
id => Entity(id, staticGuid));
|
||||
staticRemote.Body.TransientState &= ~TransientStateFlags.Active;
|
||||
staticRemote.Body.LastUpdateTime = 3.0;
|
||||
|
||||
Assert.True(runtime.TryCommitAuthoritativeVector(
|
||||
staticRecord,
|
||||
staticRemote.Body,
|
||||
new Vector3(7f, 8f, 9f),
|
||||
new Vector3(10f, 11f, 12f),
|
||||
currentTime: 10.0));
|
||||
Assert.False(staticRecord.ObjectClock.IsActive);
|
||||
Assert.False(staticRemote.Body.IsActive);
|
||||
Assert.Equal(3.0, staticRemote.Body.LastUpdateTime);
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), staticRemote.Body.Velocity);
|
||||
Assert.Equal(new Vector3(10f, 11f, 12f), staticRemote.Body.Omega);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentAndPickupInvalidateOlderPositionAndVelocityAuthority()
|
||||
{
|
||||
const uint parentGuid = 0x50000010u;
|
||||
const uint childGuid = 0x70000055u;
|
||||
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(parentGuid, 1, 1, 0x01010001u));
|
||||
LiveEntityRecord child = runtime.RegisterLiveEntity(
|
||||
Spawn(childGuid, 1, 1, 0x01010001u)).Record!;
|
||||
ulong initialPosition = child.PositionAuthorityVersion;
|
||||
ulong initialVelocity = child.VelocityAuthorityVersion;
|
||||
|
||||
Assert.True(runtime.TryApplyParent(
|
||||
new ParentEvent.Parsed(
|
||||
parentGuid,
|
||||
childGuid,
|
||||
ParentLocation: 0x00000001u,
|
||||
PlacementId: 0x00000002u,
|
||||
ParentInstanceSequence: 1,
|
||||
ChildPositionSequence: 2),
|
||||
out _));
|
||||
Assert.False(runtime.IsCurrentPositionAuthority(child, initialPosition));
|
||||
Assert.False(runtime.IsCurrentVelocityAuthority(child, initialVelocity));
|
||||
ulong parentedPosition = child.PositionAuthorityVersion;
|
||||
ulong parentedVelocity = child.VelocityAuthorityVersion;
|
||||
|
||||
Assert.True(runtime.TryApplyPickup(
|
||||
new PickupEvent.Parsed(childGuid, 1, 3),
|
||||
out _));
|
||||
Assert.False(runtime.IsCurrentPositionAuthority(child, parentedPosition));
|
||||
Assert.False(runtime.IsCurrentVelocityAuthority(child, parentedVelocity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectClock_WithdrawalRetainsIncarnationButGuidReuseGetsFreshOwner()
|
||||
{
|
||||
const uint guid = 0x70000048u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn firstSpawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||
LiveEntityRecord first = runtime.RegisterLiveEntity(firstSpawn).Record!;
|
||||
runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
firstSpawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, guid));
|
||||
RetailObjectQuantumClock firstClock = first.ObjectClock;
|
||||
Assert.Equal(0, firstClock.Advance(0.02).Count);
|
||||
|
||||
Assert.True(runtime.WithdrawLiveEntityProjection(guid));
|
||||
Assert.Same(firstClock, first.ObjectClock);
|
||||
Assert.False(firstClock.IsActive);
|
||||
|
||||
Assert.True(runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(guid, InstanceSequence: 1),
|
||||
isLocalPlayer: false));
|
||||
WorldSession.EntitySpawn secondSpawn = Spawn(guid, 2, 2, 0x01010001u);
|
||||
LiveEntityRecord second = runtime.RegisterLiveEntity(secondSpawn).Record!;
|
||||
|
||||
Assert.NotSame(first, second);
|
||||
Assert.NotSame(firstClock, second.ObjectClock);
|
||||
Assert.True(second.ObjectClock.IsActive);
|
||||
Assert.Equal(0.0, second.ObjectClock.PendingSeconds, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder()
|
||||
{
|
||||
|
|
@ -1625,6 +2178,94 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredWithdrawFalseEdge_RebucketSameRecordSupersedesOuterWithdrawal()
|
||||
{
|
||||
const uint guid = 0x70000075u;
|
||||
var spatial = new GpuWorldState();
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
WorldEntity pending = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid))!;
|
||||
Assert.False(runtime.TryGetRecord(guid, out LiveEntityRecord before)
|
||||
&& before.IsSpatiallyVisible);
|
||||
bool? nestedWithdrawResult = null;
|
||||
bool nestedWithdrawStarted = false;
|
||||
bool reprojected = false;
|
||||
runtime.ProjectionVisibilityChanged += (_, visible) =>
|
||||
{
|
||||
if (visible && !nestedWithdrawStarted)
|
||||
{
|
||||
// GpuWorldState is currently draining the outer visible edge,
|
||||
// so Withdraw must manually publish its false edge.
|
||||
nestedWithdrawStarted = true;
|
||||
nestedWithdrawResult = runtime.WithdrawLiveEntityProjection(guid);
|
||||
return;
|
||||
}
|
||||
if (!visible && !reprojected)
|
||||
{
|
||||
reprojected = true;
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x01010022u));
|
||||
}
|
||||
};
|
||||
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
|
||||
Assert.False(nestedWithdrawResult);
|
||||
Assert.True(reprojected);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
Assert.Equal(0x01010022u, record.FullCellId);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.Same(pending, record.WorldEntity);
|
||||
Assert.Same(pending, Assert.Single(runtime.MaterializedWorldEntities).Value);
|
||||
Assert.Same(pending, Assert.Single(runtime.WorldEntities).Value);
|
||||
Assert.Same(pending, Assert.Single(spatial.Entities));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterializeVisibilityCallback_GuidReplacementNeverReturnsOldTombstone()
|
||||
{
|
||||
const uint guid = 0x70000076u;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
var resources = new FailingOnceUnregisterResources(guid);
|
||||
var runtime = new LiveEntityRuntime(spatial, resources);
|
||||
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||
WorldEntity? replacementEntity = null;
|
||||
bool replaced = false;
|
||||
runtime.ProjectionVisibilityChanged += (edgeRecord, visible) =>
|
||||
{
|
||||
if (replaced || !visible || edgeRecord.Generation != 1)
|
||||
return;
|
||||
replaced = true;
|
||||
LiveEntityRegistrationResult replacement = runtime.RegisterLiveEntity(
|
||||
Spawn(guid, 2, 1, 0x01010002u));
|
||||
Assert.NotNull(replacement.PriorGenerationCleanupFailure);
|
||||
replacementEntity = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010002u,
|
||||
id => Entity(id, guid));
|
||||
};
|
||||
|
||||
WorldEntity? displaced = runtime.MaterializeLiveEntity(
|
||||
guid,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid));
|
||||
|
||||
Assert.Null(displaced);
|
||||
Assert.NotNull(replacementEntity);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
|
||||
Assert.Equal((ushort)2, current.Generation);
|
||||
Assert.Same(replacementEntity, current.WorldEntity);
|
||||
Assert.Same(replacementEntity, Assert.Single(runtime.WorldEntities).Value);
|
||||
Assert.Equal(1, runtime.PendingTeardownCount);
|
||||
Assert.Equal(1, runtime.RetryPendingTeardowns());
|
||||
Assert.Equal(0, runtime.PendingTeardownCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebucketSpatialCallback_NewerSameRecordRebucketSupersedesOuterMove()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
private static float ObjectTick => PhysicsBody.MinQuantum + 0.001f;
|
||||
|
||||
private static PhysicsEngine MakeFlatEngine()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
|
|
@ -21,6 +23,29 @@ public class PlayerMovementControllerTests
|
|||
return engine;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_ReactivatesCanonicalClock_ExceptForStaticObject()
|
||||
{
|
||||
var clock = new RetailObjectQuantumClock();
|
||||
var controller = new PlayerMovementController(MakeFlatEngine(), clock);
|
||||
clock.Deactivate();
|
||||
controller.ApplyPhysicsState(PhysicsStateFlags.None);
|
||||
|
||||
controller.Movement.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = (MovementType)99,
|
||||
});
|
||||
Assert.True(clock.IsActive);
|
||||
|
||||
clock.Deactivate();
|
||||
controller.ApplyPhysicsState(PhysicsStateFlags.Static);
|
||||
controller.Movement.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = (MovementType)99,
|
||||
});
|
||||
Assert.False(clock.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NoInput_PositionUnchanged()
|
||||
{
|
||||
|
|
@ -156,10 +181,10 @@ public class PlayerMovementControllerTests
|
|||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.AttachAnimationRootMotionSource(_ => Vector3.Zero);
|
||||
controller.AttachAnimationRootMotionSource((_, _) => { });
|
||||
|
||||
MovementResult result = controller.Update(
|
||||
PhysicsBody.MinQuantum,
|
||||
ObjectTick,
|
||||
new MovementInput(Forward: true));
|
||||
|
||||
Assert.Equal(start, result.Position);
|
||||
|
|
@ -175,10 +200,11 @@ public class PlayerMovementControllerTests
|
|||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.ObjectScale = 2f;
|
||||
controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.1f, 0f));
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f));
|
||||
|
||||
MovementResult result = controller.Update(
|
||||
PhysicsBody.MinQuantum,
|
||||
ObjectTick,
|
||||
new MovementInput(Forward: true));
|
||||
|
||||
// Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles
|
||||
|
|
@ -188,24 +214,313 @@ public class PlayerMovementControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_SubQuantumAnimationRootDeltas_AccumulateIntoOnePhysicsStep()
|
||||
public void Update_SubQuantumFrames_AdvanceAnimationOnceAtObjectThreshold()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.05f, 0f));
|
||||
int advances = 0;
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
advances++;
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f);
|
||||
});
|
||||
|
||||
MovementResult first = controller.Update(
|
||||
PhysicsBody.MinQuantum * 0.5f,
|
||||
ObjectTick * 0.5f,
|
||||
new MovementInput(Forward: true));
|
||||
MovementResult second = controller.Update(
|
||||
PhysicsBody.MinQuantum * 0.5f,
|
||||
ObjectTick * 0.5f,
|
||||
new MovementInput(Forward: true));
|
||||
|
||||
Assert.Equal(start, first.Position);
|
||||
Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3);
|
||||
Assert.Equal(start.Y, second.Position.Y, precision: 3);
|
||||
Assert.Equal(1, advances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AttachedAnimationFrame_ComposesTranslationBeforeTurn()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f; // body local +Y faces world +X
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f);
|
||||
frame.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
});
|
||||
|
||||
MovementResult result = controller.Update(
|
||||
ObjectTick,
|
||||
new MovementInput());
|
||||
|
||||
// Retail Frame::combine transforms the delta origin by the OLD body
|
||||
// orientation, then composes the delta orientation. The body therefore
|
||||
// moves east before finishing the quarter-turn to north.
|
||||
Assert.Equal(start.X + 0.1f, result.Position.X, precision: 3);
|
||||
Assert.Equal(start.Y, result.Position.Y, precision: 3);
|
||||
Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AttachedAnimationFrame_PreservesCompleteNonCommutingOrientation()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
|
||||
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
|
||||
controller.SetBodyOrientation(initial);
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
frame.Orientation = delta);
|
||||
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
|
||||
Quaternion expected = Quaternion.Normalize(initial * delta);
|
||||
float alignment = MathF.Abs(Quaternion.Dot(
|
||||
expected,
|
||||
Quaternion.Normalize(controller.BodyOrientation)));
|
||||
Assert.InRange(alignment, 0.99999f, 1.00001f);
|
||||
Assert.True(MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(delta * initial),
|
||||
Quaternion.Normalize(controller.BodyOrientation))) < 0.999f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MultiQuantumAnimationFrames_ComposeInTemporalOrder()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
int sample = 0;
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
if (sample++ == 0)
|
||||
{
|
||||
frame.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
MathF.PI / 2f);
|
||||
}
|
||||
else
|
||||
{
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f);
|
||||
}
|
||||
});
|
||||
|
||||
MovementResult result = controller.Update(
|
||||
PhysicsBody.MaxQuantum * 2f,
|
||||
new MovementInput());
|
||||
|
||||
// The second local-forward displacement occurs after the first
|
||||
// quarter-turn, so it moves north. Adding Origins as bare vectors
|
||||
// would incorrectly move east.
|
||||
Assert.Equal(start.X, result.Position.X, precision: 3);
|
||||
Assert.Equal(start.Y + 0.1f, result.Position.Y, precision: 3);
|
||||
Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ExactMinQuantum_RetainsTimeWithoutAdvancingObject()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
controller.SetPosition(start, 0x0001);
|
||||
int advances = 0;
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
advances++;
|
||||
frame.Origin = new Vector3(0f, 1f, 0f);
|
||||
});
|
||||
|
||||
MovementResult atThreshold = controller.Update(
|
||||
PhysicsBody.MinQuantum,
|
||||
new MovementInput());
|
||||
|
||||
Assert.Equal(start, atThreshold.Position);
|
||||
Assert.Equal(0, advances);
|
||||
|
||||
controller.Update(0.001f, new MovementInput());
|
||||
Assert.Equal(1, advances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LargeFrame_MatchesSeparateRetailObjectQuanta()
|
||||
{
|
||||
var combined = new PlayerMovementController(MakeFlatEngine());
|
||||
var split = new PlayerMovementController(MakeFlatEngine());
|
||||
var start = new Vector3(96f, 96f, 50f);
|
||||
combined.SetPosition(start, 0x0001);
|
||||
split.SetPosition(start, 0x0001);
|
||||
int combinedHooks = 0;
|
||||
int splitHooks = 0;
|
||||
|
||||
static void Advance(float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame frame)
|
||||
{
|
||||
frame.Origin = new Vector3(0f, dt * 2f, 0f);
|
||||
frame.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
dt * 0.7f);
|
||||
}
|
||||
|
||||
combined.AttachAnimationRootMotionSource(Advance, () => combinedHooks++);
|
||||
split.AttachAnimationRootMotionSource(Advance, () => splitHooks++);
|
||||
|
||||
combined.Update(PhysicsBody.MaxQuantum * 2f, new MovementInput());
|
||||
split.Update(PhysicsBody.MaxQuantum, new MovementInput());
|
||||
split.Update(PhysicsBody.MaxQuantum, new MovementInput());
|
||||
|
||||
Assert.Equal(split.Position.X, combined.Position.X, precision: 5);
|
||||
Assert.Equal(split.Position.Y, combined.Position.Y, precision: 5);
|
||||
Assert.Equal(split.Position.Z, combined.Position.Z, precision: 5);
|
||||
float alignment = MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(split.BodyOrientation),
|
||||
Quaternion.Normalize(combined.BodyOrientation)));
|
||||
Assert.InRange(alignment, 0.99999f, 1.00001f);
|
||||
Assert.Equal(2, combinedHooks);
|
||||
Assert.Equal(2, splitHooks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickHidden_DoesNotAdvancePartArrayButStillProcessesHooks()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
int advances = 0;
|
||||
int hookPasses = 0;
|
||||
controller.AttachAnimationRootMotionSource(
|
||||
(_, frame) =>
|
||||
{
|
||||
advances++;
|
||||
frame.Origin = new Vector3(0f, 1f, 0f);
|
||||
},
|
||||
() => hookPasses++);
|
||||
|
||||
controller.TickHidden(ObjectTick);
|
||||
|
||||
Assert.Equal(0, advances);
|
||||
Assert.Equal(1, hookPasses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidElapsed_VisibleFrameIsPurePresentationRead()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
float initialTime = controller.SimTimeSeconds;
|
||||
float initialYaw = controller.Yaw;
|
||||
Vector3 initialPosition = controller.Position;
|
||||
RawMotionState initialMotion = controller.Motion.RawState;
|
||||
var hostileInput = new MovementInput(
|
||||
Forward: true,
|
||||
TurnLeft: true,
|
||||
Jump: true,
|
||||
Run: true);
|
||||
|
||||
foreach (float elapsed in new[]
|
||||
{
|
||||
float.NaN,
|
||||
float.PositiveInfinity,
|
||||
float.NegativeInfinity,
|
||||
-0.1f,
|
||||
0f,
|
||||
})
|
||||
{
|
||||
MovementResult result = controller.Update(elapsed, hostileInput);
|
||||
Assert.False(result.ShouldSendMovementEvent);
|
||||
}
|
||||
|
||||
Assert.Equal(initialTime, controller.SimTimeSeconds);
|
||||
Assert.Equal(initialYaw, controller.Yaw);
|
||||
Assert.Equal(initialPosition, controller.Position);
|
||||
Assert.Equal(initialMotion, controller.Motion.RawState);
|
||||
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
controller.Update(ObjectTick, new MovementInput());
|
||||
Assert.True(controller.AdvancedObjectQuantumLastTick);
|
||||
controller.Update(float.NaN, hostileInput);
|
||||
Assert.False(controller.AdvancedObjectQuantumLastTick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidElapsed_HiddenFrameDoesNotAdvanceClockOrManagerTail()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
float initialTime = controller.SimTimeSeconds;
|
||||
int targetPasses = 0;
|
||||
|
||||
controller.TickHidden(float.NaN, () => targetPasses++);
|
||||
controller.TickHidden(float.PositiveInfinity, () => targetPasses++);
|
||||
controller.TickHidden(-0.1f, () => targetPasses++);
|
||||
|
||||
Assert.Equal(initialTime, controller.SimTimeSeconds);
|
||||
Assert.Equal(0, targetPasses);
|
||||
Assert.False(controller.AdvancedObjectQuantumLastTick);
|
||||
|
||||
controller.TickHidden(ObjectTick);
|
||||
controller.TickHidden(ObjectTick);
|
||||
Assert.True(controller.AdvancedObjectQuantumLastTick);
|
||||
controller.TickHidden(float.PositiveInfinity);
|
||||
Assert.False(controller.AdvancedObjectQuantumLastTick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AirbornePartArrayFrame_SuppressesOriginButPreservesOrientation()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Update(1f, new MovementInput(Jump: true));
|
||||
|
||||
Vector3 beforeRelease = controller.Position;
|
||||
Quaternion beforeOrientation = controller.BodyOrientation;
|
||||
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.4f);
|
||||
int advances = 0;
|
||||
controller.AttachAnimationRootMotionSource((_, frame) =>
|
||||
{
|
||||
advances++;
|
||||
frame.Origin = new Vector3(0f, 10f, 0f);
|
||||
frame.Orientation = delta;
|
||||
});
|
||||
|
||||
controller.Update(ObjectTick, new MovementInput(Jump: false));
|
||||
|
||||
Assert.True(controller.IsAirborne);
|
||||
Assert.Equal(beforeRelease.X, controller.Position.X, precision: 5);
|
||||
Assert.Equal(beforeRelease.Y, controller.Position.Y, precision: 5);
|
||||
Quaternion expected = Quaternion.Normalize(beforeOrientation * delta);
|
||||
float alignment = MathF.Abs(Quaternion.Dot(
|
||||
expected,
|
||||
Quaternion.Normalize(controller.BodyOrientation)));
|
||||
Assert.InRange(alignment, 0.99999f, 1.00001f);
|
||||
Assert.Equal(1, advances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AttachedAnimationTurn_IsNotAppliedByASecondYawIntegrator()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.AttachAnimationRootMotionSource((dt, frame) =>
|
||||
{
|
||||
frame.Orientation = Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
-(MathF.PI / 2f) * dt);
|
||||
});
|
||||
|
||||
controller.Update(
|
||||
ObjectTick,
|
||||
new MovementInput(TurnRight: true));
|
||||
|
||||
Assert.Equal(
|
||||
-(MathF.PI / 2f) * ObjectTick,
|
||||
controller.Yaw,
|
||||
precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -217,7 +532,7 @@ public class PlayerMovementControllerTests
|
|||
controller.SetPosition(start, 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
|
||||
var firstTick = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
var firstTick = controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
Assert.True(firstTick.Position.X > start.X, "Physics tick should advance the authoritative body position");
|
||||
Assert.Equal(start.X, firstTick.RenderPosition.X, precision: 4);
|
||||
|
||||
|
|
@ -240,7 +555,7 @@ public class PlayerMovementControllerTests
|
|||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
|
||||
controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
|
||||
|
||||
var snapped = new Vector3(120f, 80f, 50f);
|
||||
|
|
@ -257,7 +572,7 @@ public class PlayerMovementControllerTests
|
|||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
Vector3 velocity = controller.BodyVelocity;
|
||||
Assert.True(velocity.LengthSquared() > 0f);
|
||||
|
||||
|
|
@ -305,12 +620,25 @@ public class PlayerMovementControllerTests
|
|||
var controller = new PlayerMovementController(engine);
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
int animationAdvances = 0;
|
||||
int hookPasses = 0;
|
||||
controller.AttachAnimationRootMotionSource(
|
||||
(_, frame) =>
|
||||
{
|
||||
animationAdvances++;
|
||||
frame.Origin = new Vector3(0f, 0.1f, 0f);
|
||||
},
|
||||
() => hookPasses++);
|
||||
|
||||
var moved = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
var moved = controller.Update(ObjectTick, new MovementInput(Forward: true));
|
||||
int advancesBeforeDiscard = animationAdvances;
|
||||
int hooksBeforeDiscard = hookPasses;
|
||||
var stale = controller.Update(PhysicsBody.HugeQuantum + 0.1f, new MovementInput(Forward: true));
|
||||
|
||||
Assert.Equal(moved.Position.X, stale.Position.X, precision: 4);
|
||||
Assert.Equal(stale.Position, stale.RenderPosition);
|
||||
Assert.Equal(advancesBeforeDiscard, animationAdvances);
|
||||
Assert.Equal(hooksBeforeDiscard, hookPasses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -283,6 +283,27 @@ public sealed class AnimationSequencerTests
|
|||
$"Expected orientation near {rot.Z} but got {transforms[0].Orientation.Z}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InstallsSetupDefaultAnimationForEveryPartArray()
|
||||
{
|
||||
const uint AnimId = 0x0300AA01u;
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
setup.DefaultAnimation = (QualifiedDataId<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]
|
||||
public void Advance_FrameWrapsAtHighFrame()
|
||||
{
|
||||
|
|
@ -1883,6 +1904,36 @@ public sealed class AnimationSequencerTests
|
|||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void InitializeSetupDefaultAnimation_PreservesPhysicsAndPlacementState()
|
||||
{
|
||||
// CPartArray::InitDefaults (0x00518980) replaces only the animation
|
||||
// list. It does not call CSequence::clear, so the PartArray's existing
|
||||
// velocity, omega, and placement frame survive this operation.
|
||||
const uint animationId = 0x03000531u;
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(
|
||||
animationId,
|
||||
Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
|
||||
var seq = new AnimationSequencer(
|
||||
Fixtures.MakeSetup(1),
|
||||
new MotionTable(),
|
||||
loader);
|
||||
var placement = new AnimationFrame(1);
|
||||
Vector3 velocity = new(1f, 2f, 3f);
|
||||
Vector3 omega = new(0.1f, 0.2f, 0.3f);
|
||||
seq.Core.SetVelocity(velocity);
|
||||
seq.Core.SetOmega(omega);
|
||||
seq.Core.SetPlacementFrame(placement, 0x1234u);
|
||||
|
||||
Assert.True(seq.InitializeSetupDefaultAnimation(animationId));
|
||||
|
||||
Assert.Equal(velocity, seq.CurrentVelocity);
|
||||
Assert.Equal(omega, seq.CurrentOmega);
|
||||
Assert.Same(placement, seq.Core.PlacementFrame);
|
||||
Assert.Equal(0x1234u, seq.Core.PlacementFrameId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expose the core CSequence's FrameNumber via reflection (test-only).
|
||||
/// R1-P5 rehost (2026-07-02): _framePosition lived directly on
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Content.Vfx;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.Tests.Conformance;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
|
@ -23,6 +24,7 @@ public sealed class HumanoidMotionTableRootMotionTests
|
|||
private const uint RunForward = 0x40000007u;
|
||||
private const uint Ready = 0x41000003u;
|
||||
private const uint InterpretedWalkForward = 0x45000005u;
|
||||
private const uint TurnRight = 0x6500000Du;
|
||||
|
||||
[Theory]
|
||||
[InlineData(WalkForward)]
|
||||
|
|
@ -76,4 +78,32 @@ public sealed class HumanoidMotionTableRootMotionTests
|
|||
distance > 1f,
|
||||
$"Installed retail WalkForward should author root travel; got {distance:F3} m over 2 s.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetailTurnRightModifier_ExposesLiteralSequenceOmega()
|
||||
{
|
||||
string? datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
MotionTable table = Assert.IsType<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.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
|
@ -27,6 +28,164 @@ public sealed class InterpolationManagerTests
|
|||
|
||||
private static InterpolationManager Make() => new InterpolationManager();
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_CompleteFrame_ReplacesOriginAndNonCommutingOrientation()
|
||||
{
|
||||
var manager = Make();
|
||||
Quaternion current = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
|
||||
Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
|
||||
manager.Enqueue(
|
||||
new Vector3(10f, 0f, 0f),
|
||||
target,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: Vector3.Zero);
|
||||
var offset = new MotionDeltaFrame
|
||||
{
|
||||
Origin = new Vector3(7f, 8f, 9f),
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.4f),
|
||||
};
|
||||
|
||||
bool overwritten = manager.AdjustOffset(
|
||||
dt: 0.1,
|
||||
currentBodyPosition: Vector3.Zero,
|
||||
currentBodyOrientation: current,
|
||||
maxSpeedFromMinterp: 4f,
|
||||
offset);
|
||||
|
||||
Assert.True(overwritten);
|
||||
Vector3 expectedLocalOrigin = MoveToMath.GlobalToLocalVec(
|
||||
current,
|
||||
new Vector3(0.8f, 0f, 0f));
|
||||
Assert.Equal(expectedLocalOrigin.X, offset.Origin.X, 5);
|
||||
Assert.Equal(expectedLocalOrigin.Y, offset.Origin.Y, 5);
|
||||
Assert.Equal(expectedLocalOrigin.Z, offset.Origin.Z, 5);
|
||||
Quaternion expectedOrientation = Quaternion.Normalize(
|
||||
Quaternion.Inverse(current) * target);
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
expectedOrientation,
|
||||
Quaternion.Normalize(offset.Orientation))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_MoveToNode_KeepsCurrentHeading()
|
||||
{
|
||||
var manager = Make();
|
||||
manager.Enqueue(
|
||||
new Vector3(10f, 0f, 0f),
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f),
|
||||
isMovingTo: true,
|
||||
currentBodyPosition: Vector3.Zero);
|
||||
var offset = new MotionDeltaFrame
|
||||
{
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.6f),
|
||||
};
|
||||
|
||||
Assert.True(manager.AdjustOffset(
|
||||
0.1,
|
||||
Vector3.Zero,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f),
|
||||
4f,
|
||||
offset));
|
||||
|
||||
Assert.Equal(Quaternion.Identity, offset.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_UsesManagerWideLatestKeepHeadingFlag()
|
||||
{
|
||||
var manager = Make();
|
||||
Quaternion current = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f);
|
||||
manager.Enqueue(
|
||||
new Vector3(10f, 0f, 0f),
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.7f),
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: Vector3.Zero,
|
||||
currentBodyOrientation: current);
|
||||
manager.Enqueue(
|
||||
new Vector3(20f, 0f, 0f),
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, -0.6f),
|
||||
isMovingTo: true,
|
||||
currentBodyPosition: Vector3.Zero,
|
||||
currentBodyOrientation: current);
|
||||
var offset = new MotionDeltaFrame();
|
||||
|
||||
Assert.True(manager.AdjustOffset(
|
||||
0.1,
|
||||
Vector3.Zero,
|
||||
current,
|
||||
4f,
|
||||
offset));
|
||||
|
||||
// keep_heading is an InterpolationManager field in retail, not a
|
||||
// property of each queued node. The latest near enqueue therefore
|
||||
// controls the current head as well.
|
||||
Assert.Equal(Quaternion.Identity, offset.Orientation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjustOffset_WithoutContact_LeavesFrameAndQueueUntouched()
|
||||
{
|
||||
var manager = Make();
|
||||
manager.Enqueue(
|
||||
FarTarget,
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.7f),
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: BodyOrigin);
|
||||
var offset = new MotionDeltaFrame
|
||||
{
|
||||
Origin = new Vector3(1f, 2f, 3f),
|
||||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.4f),
|
||||
};
|
||||
Vector3 originalOrigin = offset.Origin;
|
||||
Quaternion originalOrientation = offset.Orientation;
|
||||
|
||||
bool overwritten = manager.AdjustOffset(
|
||||
0.1,
|
||||
BodyOrigin,
|
||||
Quaternion.Identity,
|
||||
4f,
|
||||
offset,
|
||||
inContact: false);
|
||||
|
||||
Assert.False(overwritten);
|
||||
Assert.Equal(originalOrigin, offset.Origin);
|
||||
Assert.Equal(originalOrientation, offset.Orientation);
|
||||
Assert.True(manager.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_AlreadyClose_InstallsOnlyTargetHeading()
|
||||
{
|
||||
var manager = Make();
|
||||
Quaternion target = Quaternion.Normalize(
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.8f)
|
||||
* Quaternion.CreateFromAxisAngle(Vector3.UnitZ, -0.6f));
|
||||
|
||||
Quaternion? immediate = manager.Enqueue(
|
||||
new Vector3(0.01f, 0f, 0f),
|
||||
target,
|
||||
isMovingTo: false,
|
||||
currentBodyPosition: Vector3.Zero);
|
||||
|
||||
Assert.NotNull(immediate);
|
||||
Quaternion expected = MoveToMath.SetHeading(
|
||||
target,
|
||||
MoveToMath.GetHeading(target));
|
||||
Assert.InRange(
|
||||
MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(expected),
|
||||
Quaternion.Normalize(immediate!.Value))),
|
||||
0.99999f,
|
||||
1.00001f);
|
||||
Assert.True(MathF.Abs(Quaternion.Dot(
|
||||
Quaternion.Normalize(target),
|
||||
Quaternion.Normalize(immediate.Value))) < 0.999f);
|
||||
Assert.False(manager.IsActive);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Queue mechanics
|
||||
// =========================================================================
|
||||
|
|
|
|||
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
|
||||
/// straight into <see cref="AnimationSequencer.PerformMovement"/> (no axis
|
||||
/// collection, no priority pick, no fallback chain — GetObjectSequence
|
||||
/// 0x00522860 + is_allowed decide) with the TurnApplied/TurnStopped
|
||||
/// ObservedOmega seam.
|
||||
/// 0x00522860 + is_allowed decide).
|
||||
/// </summary>
|
||||
public class MotionTableDispatchSinkTests
|
||||
{
|
||||
|
|
@ -100,15 +99,10 @@ public class MotionTableDispatchSinkTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyMotion_Turn_Branch4Modifier_FiresTurnApplied_NoCycleChange()
|
||||
public void ApplyMotion_Turn_Branch4Modifier_PreservesCycleAndAppliesDatOmega()
|
||||
{
|
||||
var seq = MakeSequencer();
|
||||
uint? turnMotion = null;
|
||||
float turnSpeed = 0f;
|
||||
var sink = new MotionTableDispatchSink(seq)
|
||||
{
|
||||
TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; },
|
||||
};
|
||||
var sink = new MotionTableDispatchSink(seq);
|
||||
|
||||
sink.ApplyMotion(Walk, 1.0f);
|
||||
sink.ApplyMotion(TurnRight, 1.5f);
|
||||
|
|
@ -116,22 +110,19 @@ public class MotionTableDispatchSinkTests
|
|||
// The AP-73 mechanism for real: substate cycle untouched, the turn
|
||||
// is a MotionState modifier with its dat omega combined.
|
||||
Assert.Equal(Walk, seq.CurrentMotion);
|
||||
Assert.Equal((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed));
|
||||
Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics()
|
||||
public void StopMotion_Turn_UnwindsModifierPhysics()
|
||||
{
|
||||
var seq = MakeSequencer();
|
||||
bool stopped = false;
|
||||
var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true };
|
||||
var sink = new MotionTableDispatchSink(seq);
|
||||
|
||||
sink.ApplyMotion(Walk, 1.0f);
|
||||
sink.ApplyMotion(TurnRight, 1.5f);
|
||||
sink.StopMotion(TurnRight);
|
||||
|
||||
Assert.True(stopped);
|
||||
// StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat
|
||||
// omega + modifier unlinked.
|
||||
Assert.Equal(0f, seq.CurrentOmega.Z, 3);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The packer-reuse trap (V0-pins §P5 correction):</b> acdream's
|
||||
/// outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is wire-correct at
|
||||
/// the QUATERNION level but its internal scalar intermediate
|
||||
/// (<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
|
||||
/// <b>The scalar-convention trap (V0-pins §P5 correction):</b>
|
||||
/// <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 -
|
||||
/// yawDeg) mod 360</c> — NOT <c>180 - yawDeg</c>.
|
||||
/// </para>
|
||||
|
|
|
|||
|
|
@ -164,6 +164,27 @@ public sealed class MovementManagerTests
|
|||
Assert.Equal(0, calls[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_ActivatesBeforeDispatch_EvenWhenTypeIsInvalid()
|
||||
{
|
||||
var (mm, _, _) = MakeFacade();
|
||||
int activations = 0;
|
||||
mm.ActivatePhysicsObject = () => activations++;
|
||||
|
||||
Assert.Equal(WeenieError.None,
|
||||
mm.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.StopCompletely,
|
||||
}));
|
||||
Assert.Equal(WeenieError.GeneralMovementFailure,
|
||||
mm.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = (MovementType)99,
|
||||
}));
|
||||
|
||||
Assert.Equal(2, activations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformMovement_MoveToType_WithoutFactory_Fails0x47()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
|
|||
// the scalar) and drains pending_motions synthetically, so the two legs where
|
||||
// the live #170 residual actually lives have ZERO coverage:
|
||||
//
|
||||
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink.
|
||||
// TurnApplied → ObservedOmega → per-tick quaternion integration →
|
||||
// MoveToMath.GetHeading → HandleTurnToHeading's HeadingGreater test;
|
||||
// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink →
|
||||
// DAT MotionData.Omega → CSequence's complete root Frame →
|
||||
// Frame::combine → HandleTurnToHeading's HeadingGreater test;
|
||||
// 2. the REAL drain: MotionTableManager pending_animations countdown fed by
|
||||
// CSequence AnimDone hooks (link-anim completions), popping
|
||||
// CMotionInterp.pending_motions via the MotionDoneTarget seam.
|
||||
|
|
@ -31,11 +31,9 @@ namespace AcDream.Core.Tests.Physics.Motion;
|
|||
// This harness wires a real MotionInterpreter + AnimationSequencer +
|
||||
// MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's
|
||||
// EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in
|
||||
// GameWindow.TickAnimations' per-entity order for the grounded branch=MOVETO
|
||||
// path (GameWindow.cs:9697 HandleTargetting → 9994 TickRemoteMoveTo →
|
||||
// 10024 get_state_velocity refresh → 10050 manual omega integration →
|
||||
// 10247 Sequencer.Advance → 10306 AnimationDone per AnimDoneHook →
|
||||
// 10309 Manager.UseTime). Wire events (aggro stance UM, mt-6 arms, attack
|
||||
// the retail per-object order: CSequence update → PositionManager adjust →
|
||||
// complete Frame combine → hook processing → Target/Movement/PartArray/
|
||||
// Position manager tail. Wire events (aggro stance UM, mt-6 arms, attack
|
||||
// UMs) replay the exact live sequence captured in launch-drainq.log
|
||||
// (2026-07-04, guid 0x80000BE5, Mite Scamp chasing the fleeing player).
|
||||
//
|
||||
|
|
@ -118,9 +116,6 @@ internal sealed class RemoteChaseHarness
|
|||
private readonly CreatureHost _creatureHost;
|
||||
private readonly TargetHost _playerHost;
|
||||
|
||||
/// <summary>GameWindow's RemoteMotion.ObservedOmega twin.</summary>
|
||||
public Vector3 ObservedOmega;
|
||||
|
||||
/// <summary>Scripted player (chase target) world position.</summary>
|
||||
public Vector3 PlayerPos;
|
||||
public Vector3 PlayerVelocity;
|
||||
|
|
@ -162,17 +157,7 @@ internal sealed class RemoteChaseHarness
|
|||
};
|
||||
|
||||
// ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ──
|
||||
Sink = new MotionTableDispatchSink(Seq)
|
||||
{
|
||||
TurnApplied = (turnMotion, turnSpeed) =>
|
||||
{
|
||||
float signed = (turnMotion & 0xFFu) == 0x0E
|
||||
? -MathF.Abs(turnSpeed)
|
||||
: turnSpeed;
|
||||
ObservedOmega = new Vector3(0f, 0f, -(MathF.PI / 2f) * signed);
|
||||
},
|
||||
TurnStopped = () => ObservedOmega = Vector3.Zero,
|
||||
};
|
||||
Sink = new MotionTableDispatchSink(Seq);
|
||||
Interp.DefaultSink = Sink;
|
||||
// #174: production binds the seam to Manager.HandleEnterWorld
|
||||
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
|
||||
|
|
@ -425,64 +410,52 @@ internal sealed class RemoteChaseHarness
|
|||
// Player (chase target) moves per its scripted velocity.
|
||||
PlayerPos += PlayerVelocity * Dt;
|
||||
|
||||
// 1. Voyeur delivery (player host HandleTargetting → this entity's
|
||||
// HandleUpdateTarget; GameWindow.cs:8094 runs before the per-remote
|
||||
// loop, 9697 in-loop).
|
||||
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
|
||||
DeliverTargetInfo();
|
||||
|
||||
// 2. MovementManager drive (TickRemoteMoveTo — the R5-V5 facade
|
||||
// relay, MovementManager::UseTime 0x005242f0).
|
||||
Movement.UseTime();
|
||||
|
||||
// 3. get_state_velocity → body velocity (the d2ccc80e refresh,
|
||||
// GameWindow.cs:10024).
|
||||
if (Body.OnWalkable)
|
||||
Body.set_local_velocity(Interp.get_state_velocity(), false);
|
||||
|
||||
// 4. Manual omega integration (GameWindow.cs:10050-10058 verbatim).
|
||||
if (ObservedOmega.LengthSquared() > 1e-8f)
|
||||
// CPartArray::Update emits one complete local Frame: authored PosFrames
|
||||
// plus MotionData velocity/omega (CSequence::apply_physics 0x00524AB0).
|
||||
var rootFrame = new Frame
|
||||
{
|
||||
float omegaMag = ObservedOmega.Length();
|
||||
var axis = ObservedOmega / omegaMag;
|
||||
float angle = omegaMag * Dt;
|
||||
var deltaRot = Quaternion.CreateFromAxisAngle(axis, angle);
|
||||
Body.Orientation = Quaternion.Normalize(
|
||||
Quaternion.Multiply(Body.Orientation, deltaRot));
|
||||
}
|
||||
Origin = Vector3.Zero,
|
||||
Orientation = Quaternion.Identity,
|
||||
};
|
||||
Seq.Advance(Dt, rootFrame);
|
||||
|
||||
// 5. R5-V3 (#171): the sticky steer — GameWindow's legacy-branch slot
|
||||
// (retail UpdatePositionInternal PositionManager::adjust_offset
|
||||
// @0x00512d0e, composed BEFORE the velocity integration). Origin is
|
||||
// mover-local; the rotation carries a RELATIVE heading (identity =
|
||||
// untouched = no turn).
|
||||
var pmDelta = new MotionDeltaFrame();
|
||||
// PositionManager receives that same Frame and may replace its motion
|
||||
// (sticky/interpolation) before Frame::combine applies translation with
|
||||
// the OLD orientation, then post-multiplies the relative orientation.
|
||||
var pmDelta = new MotionDeltaFrame
|
||||
{
|
||||
Origin = rootFrame.Origin,
|
||||
Orientation = rootFrame.Orientation,
|
||||
};
|
||||
Pm.AdjustOffset(pmDelta, Dt);
|
||||
if (pmDelta.Origin != Vector3.Zero)
|
||||
Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation);
|
||||
if (!pmDelta.Orientation.IsIdentity)
|
||||
Body.Orientation = MoveToMath.SetHeading(
|
||||
Body.Orientation,
|
||||
MoveToMath.GetHeading(Body.Orientation) + pmDelta.GetHeading());
|
||||
Body.Orientation = Quaternion.Normalize(
|
||||
Body.Orientation * pmDelta.Orientation);
|
||||
|
||||
// 5b. Position integration (UpdatePhysicsInternal, simplified: grounded,
|
||||
// no gravity participation for this scenario).
|
||||
Body.Position += Body.Velocity * Dt;
|
||||
// The fixture has no gravity or independent physical velocity, but run
|
||||
// the same physics primitive so a future fixture cannot accidentally
|
||||
// bypass the retail slot.
|
||||
Body.calc_acceleration();
|
||||
Body.UpdatePhysicsInternal(Dt);
|
||||
|
||||
// 5c. R5-V3: PositionManager::UseTime (retail UpdateObjectInternal
|
||||
// tail @0x005159b3) — the sticky 1 s lease watchdog.
|
||||
Pm.UseTime();
|
||||
|
||||
// 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone
|
||||
// hooks into the manager countdown, zero-tick sweep.
|
||||
Seq.Advance(Dt);
|
||||
// process_hooks precedes the manager tail. AnimationDone decrements the
|
||||
// exact pending-animation node that owns the transition.
|
||||
var hooks = Seq.ConsumePendingHooks();
|
||||
for (int i = 0; i < hooks.Count; i++)
|
||||
{
|
||||
if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook)
|
||||
Seq.Manager.AnimationDone(success: true);
|
||||
}
|
||||
|
||||
// UpdateObjectInternal manager tail: Target → Movement → PartArray →
|
||||
// Position. A movement selected here contributes to next tick's Frame.
|
||||
if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
|
||||
DeliverTargetInfo();
|
||||
Movement.UseTime();
|
||||
Seq.Manager.UseTime();
|
||||
Pm.UseTime();
|
||||
|
||||
// ── Observables ──
|
||||
uint substate = Seq.Manager.State.Substate;
|
||||
|
|
@ -509,7 +482,7 @@ internal sealed class RemoteChaseHarness
|
|||
public void Snapshot(string tag)
|
||||
{
|
||||
string line = FormattableString.Invariant(
|
||||
$"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={ObservedOmega.Z,6:F3}");
|
||||
$"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={Seq.CurrentOmega.Z,6:F3}");
|
||||
Trace.Add(line);
|
||||
_log?.WriteLine(line);
|
||||
}
|
||||
|
|
@ -627,7 +600,11 @@ internal sealed class RemoteChaseHarness
|
|||
AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink));
|
||||
|
||||
// Global (unstyled) TurnRight modifier — physics-only in Branch 4.
|
||||
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(TurnAnim);
|
||||
// The signed DAT omega is the rotation source, matching retail's
|
||||
// Humanoid motion table; no command-derived side channel exists.
|
||||
mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(
|
||||
TurnAnim,
|
||||
omega: new Vector3(0f, 0f, -1.5f));
|
||||
|
||||
return (setup, mt, loader);
|
||||
}
|
||||
|
|
@ -646,7 +623,7 @@ public sealed class RemoteChaseEndToEndHarnessTests
|
|||
/// The core chase cycle: aggro stance change, one mt-6 arm at a target
|
||||
/// 90° off the creature's facing, 15 m away, stationary. Retail bar: the
|
||||
/// stance links play out (~1 s), the chase turn starts, completes at the
|
||||
/// turn rate (90° at π/2·2.08 rad/s ≈ 0.5 s), and BeginMoveForward
|
||||
/// authored turn rate (90° at 1.5·2.08 rad/s ≈ 0.5 s), and BeginMoveForward
|
||||
/// installs the forward cycle. Total budget: 4 s of ticks is generous.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
|
|
@ -692,7 +669,7 @@ public sealed class RemoteChaseEndToEndHarnessTests
|
|||
Assert.True(installTick >= 0,
|
||||
$"forward cycle never installed within 6 s of the arm (bearing {bearingDeg}°); " +
|
||||
$"final: mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " +
|
||||
$"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.ObservedOmega.Z:F3}");
|
||||
$"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.Seq.CurrentOmega.Z:F3}");
|
||||
Assert.True(installTick <= Seconds(4f),
|
||||
$"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " +
|
||||
"retail installs within the turn duration (~1-2 s)");
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ public sealed class MotionVelocityPipelineTests
|
|||
Assert.True(v.X < 0f, "strafe-left velocity must be negative");
|
||||
}
|
||||
|
||||
// ── turn (drives the local Yaw omega: base π/2 × TurnSpeed) ───────────────
|
||||
// ── turn normalization (the DAT MotionData supplies production omega) ────
|
||||
|
||||
[Fact]
|
||||
public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor()
|
||||
|
|
|
|||
|
|
@ -104,6 +104,40 @@ public sealed class ProjectilePhysicsStepperTests
|
|||
Assert.Equal(50f, body.CachedVelocity.Y, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitQuantum_HoldsBeginFrameAcrossHookSlotThenCommitsCandidate()
|
||||
{
|
||||
var (engine, _) = BuildEmptyEngine();
|
||||
var body = MakeBody(
|
||||
new Vector3(12f, 10f, 2f),
|
||||
new Vector3(10f, 0f, 0f));
|
||||
var stepper = new ProjectilePhysicsStepper(engine);
|
||||
Vector3 begin = body.Position;
|
||||
|
||||
ProjectileQuantumPreparation preparation = stepper.BeginQuantum(
|
||||
body,
|
||||
0.1f,
|
||||
Cell,
|
||||
ArrowSphere);
|
||||
|
||||
Assert.True(preparation.Simulated);
|
||||
Assert.True(preparation.RequiresTransition);
|
||||
Assert.Equal(begin, body.Position);
|
||||
Assert.Equal(begin + Vector3.UnitX, preparation.CandidatePosition);
|
||||
|
||||
ProjectileAdvanceResult result = stepper.CompleteQuantum(
|
||||
body,
|
||||
preparation,
|
||||
ArrowSphere,
|
||||
ProjectileId);
|
||||
|
||||
Assert.True(result.Simulated);
|
||||
Assert.InRange(
|
||||
Vector3.Distance(begin + Vector3.UnitX, body.Position),
|
||||
0f,
|
||||
0.00001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_GravityUsesFinalPhysicsState_NotParsedAcceleration()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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