feat(vfx): port retail hidden and teleport presentation
Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
This commit is contained in:
parent
a51ebc66e9
commit
1e98d81448
46 changed files with 4883 additions and 127 deletions
|
|
@ -181,6 +181,14 @@ src/
|
||||||
AcDream.App/ Layer 1 + Layer 4 wiring
|
AcDream.App/ Layer 1 + Layer 4 wiring
|
||||||
Physics/
|
Physics/
|
||||||
ProjectileController.cs -> live-record projectile orchestration/corrections
|
ProjectileController.cs -> live-record projectile orchestration/corrections
|
||||||
|
RemotePhysicsUpdater.cs -> ordinary/Hidden remote narrow-tick integration
|
||||||
|
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 logical identity/state/spatial ownership
|
||||||
|
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
|
||||||
|
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
|
||||||
Rendering/
|
Rendering/
|
||||||
GameWindow.cs -> still owns too much runtime wiring
|
GameWindow.cs -> still owns too much runtime wiring
|
||||||
TerrainRenderer.cs -> done
|
TerrainRenderer.cs -> done
|
||||||
|
|
@ -248,6 +256,36 @@ What exists and is active:
|
||||||
owns the frame;
|
owns the frame;
|
||||||
delete, generation replacement, pickup/parent leave-world, and session reset
|
delete, generation replacement, pickup/parent leave-world, and session reset
|
||||||
use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map.
|
use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map.
|
||||||
|
- `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
|
||||||
|
unloaded destination retains one generation- and PositionSequence-scoped
|
||||||
|
pending placement and resolves the latest accepted frame when that same
|
||||||
|
projection becomes visible. It neither owns GUID identity nor reconstructs
|
||||||
|
an entity. A placement failure after hydration restores the captured source
|
||||||
|
frame/cell/contact rather than leaving a visible collisionless projection;
|
||||||
|
source-resident shadows restore immediately, while an unloaded source
|
||||||
|
delegates one incarnation-scoped restore to
|
||||||
|
`LiveEntityPresentationController`, shared with Hidden/UnHide. A newer
|
||||||
|
placement transfers that marker into an explicit active-placement generation
|
||||||
|
before rebucketing even while Hidden. That generation suppresses every
|
||||||
|
intervening Hidden/UnHide and projection restore until the controller reaches
|
||||||
|
a stable result, then it either restores after collision seating, re-defers
|
||||||
|
its rollback source, or hands a Hidden result back for UnHide. The typed
|
||||||
|
`ILiveEntityRemotePlacementRuntime` seam permits a same-incarnation wrapper
|
||||||
|
rebind only around the canonical body; pending placement adopts that wrapper
|
||||||
|
before hydration and cannot silently lose ownership. Clearing a motion or
|
||||||
|
projectile component retains the incarnation's body/contract identity until
|
||||||
|
logical teardown. The production wrapper exposes an immutable body, while
|
||||||
|
hydration defensively validates arbitrary implementations against the record
|
||||||
|
and rolls the retained body back on mismatch. Runtime binding snapshots the
|
||||||
|
interface Body getter once for validation, assignment, and state mutation.
|
||||||
|
`GpuWorldState`
|
||||||
|
rebuckets atomically and commits spatial visibility before draining its
|
||||||
|
transition FIFO; `LiveEntityRuntime` rejects delayed duplicate edges. A
|
||||||
|
rollback/rebucket inside a visibility observer therefore cannot expose a
|
||||||
|
remove/add pulse, leave stale final visibility, or reorder the final
|
||||||
|
presentation edge.
|
||||||
- `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
|
- `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
|
||||||
the transition path.
|
the transition path.
|
||||||
- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,
|
- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,
|
||||||
|
|
@ -296,7 +334,15 @@ owns spatial buckets only: register/rebucket/unregister are separate operations,
|
||||||
and landblock reloads reuse the same `WorldEntity` without replaying renderer or
|
and landblock reloads reuse the same `WorldEntity` without replaying renderer or
|
||||||
script creation. Its canonical materialized view remains stable across pending
|
script creation. Its canonical materialized view remains stable across pending
|
||||||
landblocks, while a separate visible-only view feeds radar, picking, status, and
|
landblocks, while a separate visible-only view feeds radar, picking, status, and
|
||||||
targeting. Pickup/parent leave-world clears cell membership and pauses root
|
targeting. Raw server PhysicsState and the final state produced by retail's
|
||||||
|
ordered Lighting/NoDraw/Hidden side effects are stored separately.
|
||||||
|
`LiveEntityPresentationController` drains those accepted transitions only after
|
||||||
|
the renderer/effect owner is ready: Hidden suppresses the retained root's mesh,
|
||||||
|
collision, interaction, radar, and target eligibility while preserving the
|
||||||
|
logical record, scripts, particles, lights, full cell, and local identity.
|
||||||
|
Direct attached children receive retail's NoDraw mutation, and typed
|
||||||
|
Hidden/UnHide effects resolve through the live PhysicsScriptTable.
|
||||||
|
Pickup/parent leave-world clears cell membership and pauses root
|
||||||
movement/animation without destroying retained owners. `GameWindow` retains
|
movement/animation without destroying retained owners. `GameWindow` retains
|
||||||
storage-free typed views while its large feature loops are extracted.
|
storage-free typed views while its large feature loops are extracted.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,17 @@ src/AcDream.App/
|
||||||
│ └── Vfx/ # (already exists)
|
│ └── Vfx/ # (already exists)
|
||||||
├── Net/
|
├── Net/
|
||||||
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
|
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
|
||||||
|
├── Physics/
|
||||||
|
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
|
||||||
|
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration
|
||||||
|
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
|
||||||
|
│ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions
|
||||||
|
│ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit
|
||||||
├── World/
|
├── World/
|
||||||
│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
|
│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
|
||||||
│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation
|
│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation
|
||||||
|
│ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects
|
||||||
|
│ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain
|
||||||
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations
|
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations
|
||||||
├── Interaction/
|
├── Interaction/
|
||||||
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
|
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
|
||||||
|
|
@ -231,7 +239,11 @@ instead of spread across `GameWindow`.
|
||||||
`InboundPhysicsStateController` for the nine-channel retail timestamp gates and
|
`InboundPhysicsStateController` for the nine-channel retail timestamp gates and
|
||||||
latest accepted immutable CreateObject snapshot, owns the canonical local ID
|
latest accepted immutable CreateObject snapshot, owns the canonical local ID
|
||||||
and optional runtime components, and separates logical registration from
|
and optional runtime components, and separates logical registration from
|
||||||
spatial projection. `ParentAttachmentState` is runtime-owned and keys unresolved
|
spatial projection. It also retains raw PhysicsState separately from the final
|
||||||
|
state produced by retail's ordered side effects;
|
||||||
|
`LiveEntityPresentationController` projects those transitions into draw,
|
||||||
|
collision, effect, child-NoDraw, and target visibility without becoming a
|
||||||
|
second lifetime or GUID owner. `ParentAttachmentState` is runtime-owned and keys unresolved
|
||||||
relations by child and parent generation. `Rendering/Vfx/EntityEffectController`
|
relations by child and parent generation. `Rendering/Vfx/EntityEffectController`
|
||||||
owns the focused mixed F754/F755 pending FIFO, effect profiles, typed-table
|
owns the focused mixed F754/F755 pending FIFO, effect profiles, typed-table
|
||||||
resolution, and a readiness set; canonical ServerGuid-to-local-ID translation
|
resolution, and a readiness set; canonical ServerGuid-to-local-ID translation
|
||||||
|
|
@ -241,6 +253,35 @@ allocators fail fast instead of wrapping into another landblock's namespace.
|
||||||
All other non-Parent packet
|
All other non-Parent packet
|
||||||
families still need the future general queue tracked by divergence AD-32.
|
families still need the future general queue tracked by divergence AD-32.
|
||||||
|
|
||||||
|
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,
|
||||||
|
and asks `RemoteTeleportPlacement` to collision-seat the current body when the
|
||||||
|
destination projection is available. `GameWindow` supplies lifecycle and
|
||||||
|
shadow-sync callbacks only; canonical identity remains in `LiveEntityRuntime`.
|
||||||
|
Failed hydration restores the captured source and delegates an
|
||||||
|
incarnation-scoped shadow restore to `LiveEntityPresentationController` while
|
||||||
|
that source is unloaded, so Hidden/UnHide and teleport never become competing
|
||||||
|
restore owners. A newer placement transfers that restore into an explicit
|
||||||
|
generation-scoped active-placement state before its rebucket visibility edge
|
||||||
|
even while Hidden. All intervening Hidden/UnHide and projection edges defer to
|
||||||
|
that owner until stable success or rollback completes; only then can it restore,
|
||||||
|
re-defer the source, or hand a Hidden result back for UnHide. The
|
||||||
|
`ILiveEntityRemotePlacementRuntime` seam keeps the complete cell/contact
|
||||||
|
handoff available across same-body runtime-wrapper replacement; replacing the
|
||||||
|
canonical body or dropping the placement contract within one incarnation is
|
||||||
|
rejected even after an operational component clear. `RemoteMotion.Body` is
|
||||||
|
constructor-owned; hydration compares pending/current wrappers directly to the
|
||||||
|
record body rather than trusting wrapper-to-wrapper equality. Binding reads an
|
||||||
|
interface Body getter once and reuses that snapshot. `GpuWorldState`
|
||||||
|
performs remove+place as one spatial rebucket,
|
||||||
|
then commits and serially drains visibility edges; `LiveEntityRuntime` filters
|
||||||
|
delayed duplicates. A rollback inside an observer cannot race the outer
|
||||||
|
destination-visible notification or expose an intermediate false pulse.
|
||||||
|
`LiveEntityTeardown` executes those independent owner callbacks to completion
|
||||||
|
and aggregates failures afterwards, so a throwing effect/plugin sink cannot
|
||||||
|
strand teleport, movement, shadow, light, or GUID-scoped state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Extraction sequence — safest first
|
## 4. Extraction sequence — safest first
|
||||||
|
|
|
||||||
|
|
@ -253,9 +253,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
||||||
| 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 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-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
||||||
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
||||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — teleport freshness is gated, but no remote manager teardown) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second. The retail timestamp distinction now exists; Step 8 must connect its accepted-teleport disposition to the hook teardown. | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; `SmartBox::HandleReceivedPosition` 0x00453FD0 |
|
|
||||||
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
|
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
|
||||||
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
|
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
|
||||||
|
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
||||||
|
|
||||||
- **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant.
|
- **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant.
|
||||||
- **Missile/portal VFX campaign Step 7 implemented and independently reviewed 2026-07-14.** `ProjectileController` now attaches the Step 6 body to the canonical `LiveEntityRecord` after materialization and advances arrows, bolts, and spell missiles without another GUID map, renderer registration, or stale-spawn reconstruction. It commits predicted frames through `WorldEntity.SetPosition`, republishes static effect roots, and keeps collision shadows plus canonical full-cell buckets synchronized. A predicted crossing into an unloaded bucket suspends the body and shadow in that same quantum; pending/pickup residence retains the shadow registration for exact re-entry, and hydration plus the 96-unit retail activity gate restart at the current clock without backlog. Fresh State/Vector/Position/Movement packets correct or stop the same body after retail timestamp gates; SetState reaches the canonical body before optional projectile acquisition, and a non-finite local receipt clock cannot consume first classification. Removing Missile retains ordinary active-body physics (delegating to MovementManager when present), while both owners share the body and incarnation-scoped live-record cell. CreateObject vectors are installed only at body construction, never replayed by later SetState. Delete, reset, and GUID reuse use normal logical teardown; ACE remains authoritative for impact, damage, effects, and deletion.
|
- **Missile/portal VFX campaign Step 7 implemented and independently reviewed 2026-07-14.** `ProjectileController` now attaches the Step 6 body to the canonical `LiveEntityRecord` after materialization and advances arrows, bolts, and spell missiles without another GUID map, renderer registration, or stale-spawn reconstruction. It commits predicted frames through `WorldEntity.SetPosition`, republishes static effect roots, and keeps collision shadows plus canonical full-cell buckets synchronized. A predicted crossing into an unloaded bucket suspends the body and shadow in that same quantum; pending/pickup residence retains the shadow registration for exact re-entry, and hydration plus the 96-unit retail activity gate restart at the current clock without backlog. Fresh State/Vector/Position/Movement packets correct or stop the same body after retail timestamp gates; SetState reaches the canonical body before optional projectile acquisition, and a non-finite local receipt clock cannot consume first classification. Removing Missile retains ordinary active-body physics (delegating to MovementManager when present), while both owners share the body and incarnation-scoped live-record cell. CreateObject vectors are installed only at body construction, never replayed by later SetState. Delete, reset, and GUID reuse use normal logical teardown; ACE remains authoritative for impact, damage, effects, and deletion.
|
||||||
|
- **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains because the separate 25-second/384-metre liveness cull is still unported.
|
||||||
|
|
||||||
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
|
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -471,8 +471,8 @@ include dungeons.
|
||||||
`PlayerDescription.Options1` preserves retail's player secure-trade
|
`PlayerDescription.Options1` preserves retail's player secure-trade
|
||||||
preference. See
|
preference. See
|
||||||
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
|
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
|
||||||
- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–7
|
- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–8
|
||||||
implemented and independently reviewed 2026-07-14)** — named-retail projectile and
|
independently reviewed 2026-07-14)** — named-retail projectile and
|
||||||
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
|
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
|
||||||
and F754/F755 wire surfaces are parsed with retail timestamp gates, and
|
and F754/F755 wire surfaces are parsed with retail timestamp gates, and
|
||||||
`LiveEntityRuntime` now separates logical lifetime from spatial rebucketing.
|
`LiveEntityRuntime` now separates logical lifetime from spatial rebucketing.
|
||||||
|
|
@ -526,8 +526,15 @@ include dungeons.
|
||||||
if Missile classification changes while pending; static missile effect
|
if Missile classification changes while pending; static missile effect
|
||||||
roots follow every predicted frame, and materialized rehydration never uses a
|
roots follow every predicted frame, and materialized rehydration never uses a
|
||||||
stale spawn cell. No impact, damage, effect, or delete event is synthesized
|
stale spawn cell. No impact, damage, effect, or delete event is synthesized
|
||||||
client-side. The remaining steps port
|
client-side. Step 8 ports retail's constructor-state → PhysicsDesc
|
||||||
Hidden/UnHide portal presentation and run final hardening/visual gates.
|
transition, ordered Lighting/NoDraw/Hidden state side effects, DAT-driven
|
||||||
|
typed Hidden/UnHide effects, direct-child NoDraw propagation, retained
|
||||||
|
particles/lights, mesh/collision/interaction suppression, and exact remote
|
||||||
|
`teleport_hook` teardown followed by a placement snap before the airborne
|
||||||
|
gate. Ordinary visible CreateObject does not fabricate UnHide; pending
|
||||||
|
F754/F755 packets replay only after construction-state effects. TS-43 is
|
||||||
|
retired. The remaining Step 9 is final hardening plus the two-client visual
|
||||||
|
gate.
|
||||||
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** —
|
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** —
|
||||||
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
|
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
|
||||||
funnel and action-stamp gate, so ACE's server-selected melee/missile action
|
funnel and action-stamp gate, so ACE's server-selected melee/missile action
|
||||||
|
|
|
||||||
|
|
@ -698,8 +698,110 @@ The recall Animation's direct DAT hooks must be executed rather than replaced
|
||||||
with a guessed cloud. Hidden/UnHide state transitions and explicit typed
|
with a guessed cloud. Hidden/UnHide state transitions and explicit typed
|
||||||
scripts remain distinct packet-driven events. A normal visible spawn alone is
|
scripts remain distinct packet-driven events. A normal visible spawn alone is
|
||||||
not evidence for an UnHide materialization effect; the observer portal-in
|
not evidence for an UnHide materialization effect; the observer portal-in
|
||||||
sequence must be validated against its actual inbound state/effect packets
|
sequence remains a final two-client packet/visual gate.
|
||||||
during Step 8.
|
|
||||||
|
### 11.5 Step 8 implementation boundary
|
||||||
|
|
||||||
|
The port keeps raw wire state and side-effect-derived final state separate.
|
||||||
|
`LiveEntityPresentationController` drains accepted state transitions only after
|
||||||
|
the renderer, effect profile, and PhysicsScript owner are ready; pending mixed
|
||||||
|
F754/F755 packets replay after that construction-state drain. Hidden removes
|
||||||
|
the retained entity from drawing, collision, radar, picking, status, and new
|
||||||
|
target acquisition, but does not unregister its record, cell, script queue,
|
||||||
|
particles, lights, or canonical local ID. Attached children receive the exact
|
||||||
|
direct NoDraw mutation.
|
||||||
|
|
||||||
|
The Hidden update is a narrow tick, not a frozen entity. In
|
||||||
|
`CPhysicsObj::UpdatePositionInternal` (`0x00512C30`) Hidden skips PartArray
|
||||||
|
root-motion/animation advancement and `UpdatePhysicsInternal`, but still
|
||||||
|
composes `PositionManager::adjust_offset` and processes timed object hooks.
|
||||||
|
The surrounding `UpdateObjectInternal` (`0x005156B0`) then continues the
|
||||||
|
target, movement, and position-manager tails. A moving Hidden local or remote
|
||||||
|
therefore updates its root/cell and effect-pose snapshot without accepting
|
||||||
|
input, gravity, or ordinary body integration.
|
||||||
|
|
||||||
|
Retail `CObjCell::hide_object` (`0x0052BE30`) also sends DetectionManager
|
||||||
|
`LeftDetection`. DetectionManager is not yet ported, so TS-49 records the one
|
||||||
|
explicit adaptation: acdream sends the existing TargetManager non-Ok
|
||||||
|
availability edge to tear down MoveTo/Sticky consumers and clears the hidden
|
||||||
|
object's watched-role subscriptions.
|
||||||
|
|
||||||
|
Fresh remote `TELEPORT_TS`, or first placement while the canonical cell is
|
||||||
|
zero, follows `MoveOrTeleport` Branch A before its contact test:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CancelMoveTo(0x3c)
|
||||||
|
UnStick
|
||||||
|
StopInterpolating
|
||||||
|
UnConstrain
|
||||||
|
ClearTarget + NotifyVoyeurOfEvent(Teleported)
|
||||||
|
report_collision_end
|
||||||
|
SetPosition the same body at received full cell + frame through CTransition
|
||||||
|
derive Contact/OnWalkable/contact plane and ground-transition state
|
||||||
|
```
|
||||||
|
|
||||||
|
This order is required even for an airborne packet; the ordinary airborne
|
||||||
|
no-op is only a Branch B/C decision and cannot undo a genuine teleport. A
|
||||||
|
canonical nonzero cell whose spatial projection is unavailable is classified
|
||||||
|
as cell-less for this decision: it cannot take the ordinary interpolation path
|
||||||
|
without a resident cell.
|
||||||
|
|
||||||
|
If the destination landblock is pending, `RemoteTeleportController` parks the
|
||||||
|
authoritative frame without contact, retains the original contact edge, and
|
||||||
|
waits for projection visibility. The request is scoped to live generation and
|
||||||
|
accepted PositionSequence. Every newer accepted Position packet refreshes that
|
||||||
|
same request; hydration collision-seats only the latest sequence, while delete,
|
||||||
|
reset, or GUID reuse discards the old incarnation's placement.
|
||||||
|
|
||||||
|
`SetPositionInternal` captures the old Contact/OnWalkable flags at entry. It
|
||||||
|
writes destination Contact while retaining the source OnWalkable bit for the
|
||||||
|
first `calc_acceleration`, then invokes `set_on_walkable` (therefore
|
||||||
|
HitGround/LeaveGround), and only then calls `handle_all_collisions` at
|
||||||
|
`0x005154FE`. Retaining source walkability through that first calculation
|
||||||
|
matters for a grounded source that hydrates onto a steep contact: retail clears
|
||||||
|
grounded angular drift before installing the destination's non-walkable state.
|
||||||
|
The callback may change velocity, so collision response must consume that
|
||||||
|
post-callback velocity while still receiving the captured source flags. This
|
||||||
|
ordering is shared by loaded teleports and local/remote Hidden PositionManager
|
||||||
|
movement. A Hidden projectile that shares the same RemoteMotion body also uses
|
||||||
|
this narrow manager tick once; projectile integration remains paused.
|
||||||
|
|
||||||
|
If projection hydration occurs but placement resolution fails, the visibility
|
||||||
|
edge cannot be treated as a retry clock. The controller restores the captured
|
||||||
|
source body frame, full cell, contact state, and projection, then drops the
|
||||||
|
pending request. A resident source restores its shadow immediately; an unloaded
|
||||||
|
source delegates one generation-scoped restore to
|
||||||
|
`LiveEntityPresentationController`, the same owner used by Hidden/UnHide, until
|
||||||
|
that projection returns. Before any newer accepted placement rebuckets, it
|
||||||
|
transfers the deferred restore into an explicit active-placement generation,
|
||||||
|
including while Hidden. That ownership remains active for the complete pending
|
||||||
|
lifetime, so any number of intervening Hidden/UnHide or visibility edges cannot
|
||||||
|
restore an unresolved pose. Only the controller's stable completion clears it:
|
||||||
|
the placement either restores after successful collision seating, re-defers its
|
||||||
|
captured source after failure, or hands a stable Hidden result back for UnHide.
|
||||||
|
A same-incarnation MovementManager/runtime rebind cannot replace the canonical
|
||||||
|
physics body or discard the typed placement contract. A wrapper replacement
|
||||||
|
around that same body is adopted by the pending request before hydration, so
|
||||||
|
component rebinding cannot strand active-placement ownership. Operational
|
||||||
|
component clearing removes the wrapper, not the incarnation's body identity or
|
||||||
|
placement requirement; only logical teardown releases those invariants. The
|
||||||
|
production wrapper's body is constructor-owned and immutable, and hydration
|
||||||
|
validates both pending/current wrappers against the record's captured body. A
|
||||||
|
malformed dynamic wrapper is detached and the canonical body is rolled back.
|
||||||
|
Every runtime-binding boundary reads the wrapper's Body exactly once, validates
|
||||||
|
that local snapshot, and installs the same reference into the live record.
|
||||||
|
A cell-less source withdraws the projection and publishes the final
|
||||||
|
invisible presentation edge. Spatial rebucketing removes
|
||||||
|
and places atomically, visibility transitions commit their new set first and
|
||||||
|
drain through a non-reentrant FIFO, and `LiveEntityRuntime` rejects delayed
|
||||||
|
duplicates against its last logical state. A rollback triggered by a
|
||||||
|
destination-visible callback therefore cannot expose remove/add pulses or be
|
||||||
|
overwritten by the outer notification.
|
||||||
|
A newer loaded destination that fails placement consumes the original pending
|
||||||
|
request and performs the same rollback. Logical teardown is independently
|
||||||
|
failure-isolated across presentation, effects, teleport, movement/target,
|
||||||
|
shadow, and light owners; session reset clears pending teleport state even when
|
||||||
|
another teardown callback throws.
|
||||||
|
|
||||||
## 12. Secondary-reference cross-checks
|
## 12. Secondary-reference cross-checks
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,17 @@ public sealed class PlayerMovementController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint LocalEntityId { get; set; }
|
public uint LocalEntityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the canonical server PhysicsState to the local body. Retail's
|
||||||
|
/// <c>CPhysicsObj::SetState</c> replaces these persistent bits before its
|
||||||
|
/// next acceleration/integration decision.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyPhysicsState(PhysicsStateFlags state)
|
||||||
|
{
|
||||||
|
_body.State = state;
|
||||||
|
_body.calc_acceleration();
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsAirborne => !_body.OnWalkable;
|
public bool IsAirborne => !_body.OnWalkable;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -571,6 +582,86 @@ public sealed class PlayerMovementController
|
||||||
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
|
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail Hidden slice of <c>CPhysicsObj::UpdatePositionInternal</c>
|
||||||
|
/// (0x00512C30). Input, PartArray root motion, and physics integration are
|
||||||
|
/// skipped, while PositionManager composition and the manager tail remain
|
||||||
|
/// live. A composed offset still commits through CTransition so cell
|
||||||
|
/// membership and contact state cannot become stale behind the hidden mesh.
|
||||||
|
/// </summary>
|
||||||
|
public MovementResult TickHidden(float dt)
|
||||||
|
{
|
||||||
|
_simTimeSeconds += dt;
|
||||||
|
_physicsAccum = 0f;
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_body.Position != previousPosition && CellId != 0 && _physics.LandblockCount > 0)
|
||||||
|
{
|
||||||
|
ResolveResult resolved = _physics.ResolveWithTransition(
|
||||||
|
previousPosition,
|
||||||
|
_body.Position,
|
||||||
|
CellId,
|
||||||
|
sphereRadius: 0.48f,
|
||||||
|
sphereHeight: 1.835f,
|
||||||
|
stepUpHeight: StepUpHeight,
|
||||||
|
stepDownHeight: StepDownHeight,
|
||||||
|
isOnGround: previousOnWalkable,
|
||||||
|
body: _body,
|
||||||
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||||
|
movingEntityId: LocalEntityId);
|
||||||
|
_body.Position = resolved.Position;
|
||||||
|
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||||
|
_body,
|
||||||
|
resolved.InContact,
|
||||||
|
resolved.OnWalkable,
|
||||||
|
resolved.CollisionNormalValid,
|
||||||
|
resolved.CollisionNormal,
|
||||||
|
previousContact,
|
||||||
|
previousOnWalkable,
|
||||||
|
Movement.HitGround,
|
||||||
|
_motion.LeaveGround);
|
||||||
|
UpdateCellId(resolved.CellId, "hidden-position-manager");
|
||||||
|
}
|
||||||
|
|
||||||
|
Movement.UseTime();
|
||||||
|
PositionManager?.UseTime();
|
||||||
|
_prevPhysicsPos = _body.Position;
|
||||||
|
_currPhysicsPos = _body.Position;
|
||||||
|
_wasAirborneLastFrame = !_body.OnWalkable;
|
||||||
|
|
||||||
|
return new MovementResult(
|
||||||
|
Position: _body.Position,
|
||||||
|
RenderPosition: _body.Position,
|
||||||
|
CellId: CellId,
|
||||||
|
IsOnGround: _body.OnWalkable,
|
||||||
|
MotionStateChanged: false,
|
||||||
|
ForwardCommand: null,
|
||||||
|
SidestepCommand: null,
|
||||||
|
TurnCommand: null,
|
||||||
|
ForwardSpeed: null,
|
||||||
|
SidestepSpeed: null,
|
||||||
|
TurnSpeed: null);
|
||||||
|
}
|
||||||
|
|
||||||
public MovementResult Update(float dt, MovementInput input)
|
public MovementResult Update(float dt, MovementInput input)
|
||||||
{
|
{
|
||||||
_simTimeSeconds += dt;
|
_simTimeSeconds += dt;
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ internal sealed class ProjectileController
|
||||||
entity.MeshRefs.Count > 0);
|
entity.MeshRefs.Count > 0);
|
||||||
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
|
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
|
||||||
|
|
||||||
if (HasVisibleCell(record))
|
if (HasVisibleCell(record) && !IsHidden(record))
|
||||||
{
|
{
|
||||||
ShadowPositionSynchronizer.Sync(
|
ShadowPositionSynchronizer.Sync(
|
||||||
_shadows,
|
_shadows,
|
||||||
|
|
@ -262,6 +262,15 @@ internal sealed class ProjectileController
|
||||||
liveCenterX,
|
liveCenterX,
|
||||||
liveCenterY);
|
liveCenterY);
|
||||||
}
|
}
|
||||||
|
else if (HasVisibleCell(record))
|
||||||
|
{
|
||||||
|
// Hidden is a retained in-world CPhysicsObj, not leave_world.
|
||||||
|
// Keep Active/identity but remove collision rows and consume the
|
||||||
|
// hidden clock so UnHide cannot replay a time backlog.
|
||||||
|
body.InWorld = true;
|
||||||
|
body.LastUpdateTime = currentTime;
|
||||||
|
_shadows.Suspend(entity.Id);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SuspendOutsideWorld(runtime, entity.Id);
|
SuspendOutsideWorld(runtime, entity.Id);
|
||||||
|
|
@ -300,7 +309,8 @@ internal sealed class ProjectileController
|
||||||
|
|
||||||
SetVelocity(runtime.Body, velocity, currentTime);
|
SetVelocity(runtime.Body, velocity, currentTime);
|
||||||
runtime.Body.Omega = angularVelocity;
|
runtime.Body.Omega = angularVelocity;
|
||||||
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid))
|
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)
|
||||||
|
&& !IsHidden(record))
|
||||||
Deactivate(runtime.Body);
|
Deactivate(runtime.Body);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -425,7 +435,7 @@ internal sealed class ProjectileController
|
||||||
entity.Rotation = orientation;
|
entity.Rotation = orientation;
|
||||||
entity.ParentCellId = canonicalCellId;
|
entity.ParentCellId = canonicalCellId;
|
||||||
_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId);
|
_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId);
|
||||||
if (HasVisibleCell(record))
|
if (HasVisibleCell(record) && !IsHidden(record))
|
||||||
{
|
{
|
||||||
if (!wasInWorld)
|
if (!wasInWorld)
|
||||||
Activate(body, currentTime);
|
Activate(body, currentTime);
|
||||||
|
|
@ -439,6 +449,12 @@ internal sealed class ProjectileController
|
||||||
liveCenterX,
|
liveCenterX,
|
||||||
liveCenterY);
|
liveCenterY);
|
||||||
}
|
}
|
||||||
|
else if (HasVisibleCell(record))
|
||||||
|
{
|
||||||
|
body.InWorld = true;
|
||||||
|
body.LastUpdateTime = currentTime;
|
||||||
|
_shadows.Suspend(entity.Id);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SuspendOutsideWorld(runtime, entity.Id);
|
SuspendOutsideWorld(runtime, entity.Id);
|
||||||
|
|
@ -496,6 +512,27 @@ internal sealed class ProjectileController
|
||||||
|| record.WorldEntity is not { } entity)
|
|| record.WorldEntity is not { } entity)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
runtime.Body.State = record.FinalPhysicsState;
|
||||||
|
if (IsHidden(record))
|
||||||
|
{
|
||||||
|
if (!HasVisibleCell(record))
|
||||||
|
{
|
||||||
|
SuspendOutsideWorld(runtime, entity.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// UpdatePositionInternal skips root physics/PartArray
|
||||||
|
// while Hidden, but update_object still advances its
|
||||||
|
// clock and does not clear Active.
|
||||||
|
runtime.Body.InWorld = true;
|
||||||
|
runtime.Body.LastUpdateTime = currentTime;
|
||||||
|
if ((record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0)
|
||||||
|
Deactivate(runtime.Body);
|
||||||
|
_shadows.Suspend(entity.Id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
bool isMissile =
|
bool isMissile =
|
||||||
(record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0;
|
(record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0;
|
||||||
if (!isMissile)
|
if (!isMissile)
|
||||||
|
|
@ -530,7 +567,6 @@ internal sealed class ProjectileController
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.Body.State = record.FinalPhysicsState;
|
|
||||||
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid))
|
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid))
|
||||||
{
|
{
|
||||||
if (!HasVisibleCell(record))
|
if (!HasVisibleCell(record))
|
||||||
|
|
@ -637,6 +673,9 @@ internal sealed class ProjectileController
|
||||||
&& record.IsSpatiallyVisible
|
&& record.IsSpatiallyVisible
|
||||||
&& record.FullCellId != 0;
|
&& record.FullCellId != 0;
|
||||||
|
|
||||||
|
private static bool IsHidden(LiveEntityRecord record) =>
|
||||||
|
(record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
|
||||||
|
|
||||||
private void SuspendOutsideWorld(Runtime runtime, uint localEntityId)
|
private void SuspendOutsideWorld(Runtime runtime, uint localEntityId)
|
||||||
{
|
{
|
||||||
runtime.Body.InWorld = false;
|
runtime.Body.InWorld = false;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ namespace AcDream.App.Physics;
|
||||||
/// frame, from inside the same guard the body used to live under
|
/// frame, from inside the same guard the body used to live under
|
||||||
/// (<c>ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
|
/// (<c>ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
|
||||||
/// && rm.LastServerPosTime > 0</c>).
|
/// && rm.LastServerPosTime > 0</c>).
|
||||||
|
/// Hidden remotes use a separate live-entity pass because retail keeps their
|
||||||
|
/// PositionManager alive even when the object has no render-animation owner.
|
||||||
///
|
///
|
||||||
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
|
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
|
||||||
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
|
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
|
||||||
|
|
@ -55,6 +57,35 @@ internal sealed class RemotePhysicsUpdater
|
||||||
// Duplicated one-liner (GameWindow keeps its own copy — many callers there).
|
// Duplicated one-liner (GameWindow keeps its own copy — many callers there).
|
||||||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances the retained PositionManager path for every hidden remote,
|
||||||
|
/// including objects without an <c>AnimatedEntity</c> (missiles commonly
|
||||||
|
/// have no PartArray). Retail gates this path on the live CPhysicsObj, not
|
||||||
|
/// on whether a render-animation owner exists.
|
||||||
|
/// </summary>
|
||||||
|
public void TickHiddenEntities(
|
||||||
|
AcDream.App.World.LiveEntityRuntime liveEntities,
|
||||||
|
uint localPlayerServerGuid,
|
||||||
|
float dt,
|
||||||
|
System.Action<AcDream.Core.World.WorldEntity> publishRootPose)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(liveEntities);
|
||||||
|
ArgumentNullException.ThrowIfNull(publishRootPose);
|
||||||
|
|
||||||
|
foreach (AcDream.App.World.LiveEntityRecord record in liveEntities.Records.ToArray())
|
||||||
|
{
|
||||||
|
if (record.ServerGuid == localPlayerServerGuid
|
||||||
|
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|
||||||
|
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|
||||||
|
|| record.RemoteMotionRuntime is not RemoteMotion remote
|
||||||
|
|| record.WorldEntity is not { } entity)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
TickHidden(remote, entity, dt);
|
||||||
|
publishRootPose(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
|
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
|
||||||
/// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard
|
/// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard
|
||||||
|
|
@ -556,6 +587,99 @@ internal sealed class RemotePhysicsUpdater
|
||||||
rm.Host?.PositionManager.UseTime();
|
rm.Host?.PositionManager.UseTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail hidden-object slice of <c>CPhysicsObj::UpdatePositionInternal</c>
|
||||||
|
/// (<c>0x00512C30</c>) plus the manager tail of
|
||||||
|
/// <c>UpdateObjectInternal</c> (<c>0x005156B0</c>). Hidden skips
|
||||||
|
/// <c>CPartArray::Update</c> and <c>UpdatePhysicsInternal</c>, but the
|
||||||
|
/// PositionManager offset is still composed and the target, movement, and
|
||||||
|
/// position managers still consume time. Physics-script and particle owners
|
||||||
|
/// tick later in the shared frame pipeline.
|
||||||
|
/// </summary>
|
||||||
|
public void TickHidden(
|
||||||
|
RemoteMotion rm,
|
||||||
|
AcDream.Core.World.WorldEntity entity,
|
||||||
|
float dt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rm);
|
||||||
|
ArgumentNullException.ThrowIfNull(entity);
|
||||||
|
|
||||||
|
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(
|
||||||
|
dt,
|
||||||
|
rm.Body.Position,
|
||||||
|
System.Numerics.Vector3.Zero,
|
||||||
|
rm.Body.Orientation,
|
||||||
|
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.Host?.PositionManager.AdjustOffset(positionDelta, dt);
|
||||||
|
ApplyPositionManagerDelta(rm.Body, positionDelta);
|
||||||
|
|
||||||
|
System.Numerics.Vector3 composedPosition = rm.Body.Position;
|
||||||
|
if (rm.CellId != 0
|
||||||
|
&& composedPosition != preComposePosition
|
||||||
|
&& _physicsEngine.LandblockCount > 0)
|
||||||
|
{
|
||||||
|
var (radius, height) = _getSetupCylinder(entity.ServerGuid, entity);
|
||||||
|
if (radius < 0.05f)
|
||||||
|
{
|
||||||
|
radius = 0.48f;
|
||||||
|
height = 1.835f;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool previousContact = rm.Body.InContact;
|
||||||
|
bool previousOnWalkable = rm.Body.OnWalkable;
|
||||||
|
var resolved = _physicsEngine.ResolveWithTransition(
|
||||||
|
preComposePosition,
|
||||||
|
composedPosition,
|
||||||
|
rm.CellId,
|
||||||
|
radius,
|
||||||
|
height,
|
||||||
|
stepUpHeight: 0.4f,
|
||||||
|
stepDownHeight: 0.4f,
|
||||||
|
isOnGround: previousOnWalkable,
|
||||||
|
body: rm.Body,
|
||||||
|
moverFlags: IsPlayerGuid(entity.ServerGuid)
|
||||||
|
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||||
|
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
|
||||||
|
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||||
|
movingEntityId: entity.Id);
|
||||||
|
rm.Body.Position = resolved.Position;
|
||||||
|
if (resolved.CellId != 0)
|
||||||
|
rm.CellId = resolved.CellId;
|
||||||
|
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
|
||||||
|
rm.Body,
|
||||||
|
resolved.InContact,
|
||||||
|
resolved.OnWalkable,
|
||||||
|
resolved.CollisionNormalValid,
|
||||||
|
resolved.CollisionNormal,
|
||||||
|
previousContact,
|
||||||
|
previousOnWalkable,
|
||||||
|
rm.Movement.HitGround,
|
||||||
|
rm.Motion.LeaveGround);
|
||||||
|
rm.Airborne = !rm.Body.OnWalkable;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.SetPosition(rm.Body.Position);
|
||||||
|
if (rm.CellId != 0)
|
||||||
|
entity.ParentCellId = rm.CellId;
|
||||||
|
entity.Rotation = rm.Body.Orientation;
|
||||||
|
|
||||||
|
rm.Host?.HandleTargetting();
|
||||||
|
TickRemoteMoveTo(rm);
|
||||||
|
rm.Host?.PositionManager.UseTime();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
|
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
|
||||||
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
|
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
|
||||||
|
|
@ -605,16 +729,36 @@ internal sealed class RemotePhysicsUpdater
|
||||||
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
|
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
|
||||||
/// UP-branch tail.
|
/// UP-branch tail.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY)
|
public void SyncRemoteShadowToBody(
|
||||||
|
uint entityId,
|
||||||
|
AcDream.App.World.ILiveEntityRemotePlacementRuntime rm,
|
||||||
|
int liveCenterX,
|
||||||
|
int liveCenterY,
|
||||||
|
uint? authoritativeCellId = null)
|
||||||
|
{
|
||||||
|
SyncRemoteShadowToBody(
|
||||||
|
entityId,
|
||||||
|
rm.Body,
|
||||||
|
liveCenterX,
|
||||||
|
liveCenterY,
|
||||||
|
authoritativeCellId ?? rm.CellId);
|
||||||
|
rm.LastShadowSyncPosition = rm.Body.Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SyncRemoteShadowToBody(
|
||||||
|
uint entityId,
|
||||||
|
AcDream.Core.Physics.PhysicsBody body,
|
||||||
|
int liveCenterX,
|
||||||
|
int liveCenterY,
|
||||||
|
uint authoritativeCellId)
|
||||||
{
|
{
|
||||||
ShadowPositionSynchronizer.Sync(
|
ShadowPositionSynchronizer.Sync(
|
||||||
_physicsEngine.ShadowObjects,
|
_physicsEngine.ShadowObjects,
|
||||||
entityId,
|
entityId,
|
||||||
rm.Body.Position,
|
body.Position,
|
||||||
rm.Body.Orientation,
|
body.Orientation,
|
||||||
rm.CellId,
|
authoritativeCellId,
|
||||||
liveCenterX,
|
liveCenterX,
|
||||||
liveCenterY);
|
liveCenterY);
|
||||||
rm.LastShadowSyncPos = rm.Body.Position;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
444
src/AcDream.App/Physics/RemoteTeleportController.cs
Normal file
444
src/AcDream.App/Physics/RemoteTeleportController.cs
Normal file
|
|
@ -0,0 +1,444 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the SetPosition half of retail remote
|
||||||
|
/// <c>CPhysicsObj::MoveOrTeleport</c> (0x00516330). It resolves the
|
||||||
|
/// destination through the placement transition, commits that transition to
|
||||||
|
/// the existing body, and synchronizes the remote movement/contact state.
|
||||||
|
/// Logical identity, target-hook actions, rebucketing callbacks, and render
|
||||||
|
/// presentation remain with their existing owners.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RemoteTeleportController : IDisposable
|
||||||
|
{
|
||||||
|
internal delegate ResolveResult PlacementResolver(
|
||||||
|
Vector3 position,
|
||||||
|
uint cellId,
|
||||||
|
float radius,
|
||||||
|
float height,
|
||||||
|
ObjectInfoState moverFlags,
|
||||||
|
uint movingEntityId);
|
||||||
|
|
||||||
|
private readonly PhysicsEngine _physics;
|
||||||
|
private readonly LiveEntityRuntime _liveEntities;
|
||||||
|
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _getSetupCylinder;
|
||||||
|
private readonly Func<Vector3, uint, Vector3> _cellLocalForSeed;
|
||||||
|
private readonly Action<WorldEntity, PhysicsBody, uint> _syncResolvedShadow;
|
||||||
|
private readonly Action<uint, ushort, bool> _completeAuthoritativePlacement;
|
||||||
|
private readonly Action<uint, ushort> _beginAuthoritativePlacement;
|
||||||
|
private readonly PlacementResolver _resolvePlacement;
|
||||||
|
private readonly Dictionary<uint, PendingPlacement> _pending = new();
|
||||||
|
|
||||||
|
internal RemoteTeleportController(
|
||||||
|
PhysicsEngine physics,
|
||||||
|
LiveEntityRuntime liveEntities,
|
||||||
|
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder,
|
||||||
|
Func<Vector3, uint, Vector3> cellLocalForSeed,
|
||||||
|
Action<WorldEntity, PhysicsBody, uint> syncResolvedShadow,
|
||||||
|
Action<uint, ushort, bool> completeAuthoritativePlacement,
|
||||||
|
Action<uint, ushort> beginAuthoritativePlacement,
|
||||||
|
PlacementResolver? resolvePlacement = null)
|
||||||
|
{
|
||||||
|
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||||
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||||
|
_getSetupCylinder = getSetupCylinder
|
||||||
|
?? throw new ArgumentNullException(nameof(getSetupCylinder));
|
||||||
|
_cellLocalForSeed = cellLocalForSeed
|
||||||
|
?? throw new ArgumentNullException(nameof(cellLocalForSeed));
|
||||||
|
_syncResolvedShadow = syncResolvedShadow
|
||||||
|
?? throw new ArgumentNullException(nameof(syncResolvedShadow));
|
||||||
|
_completeAuthoritativePlacement = completeAuthoritativePlacement
|
||||||
|
?? throw new ArgumentNullException(nameof(completeAuthoritativePlacement));
|
||||||
|
_beginAuthoritativePlacement = beginAuthoritativePlacement
|
||||||
|
?? throw new ArgumentNullException(nameof(beginAuthoritativePlacement));
|
||||||
|
_resolvePlacement = resolvePlacement ?? ResolvePlacement;
|
||||||
|
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct Result(
|
||||||
|
bool Applied,
|
||||||
|
bool ContactResolved,
|
||||||
|
Vector3 Position,
|
||||||
|
uint CellId,
|
||||||
|
Quaternion Orientation);
|
||||||
|
|
||||||
|
private readonly record struct PendingPlacement(
|
||||||
|
ILiveEntityRemotePlacementRuntime Remote,
|
||||||
|
PhysicsBody Body,
|
||||||
|
WorldEntity Entity,
|
||||||
|
Vector3 RequestedWorldPosition,
|
||||||
|
uint RequestedCellId,
|
||||||
|
Quaternion RequestedOrientation,
|
||||||
|
double GameTime,
|
||||||
|
ushort Generation,
|
||||||
|
ushort PositionSequence,
|
||||||
|
bool WasInContact,
|
||||||
|
bool WasOnWalkable,
|
||||||
|
RollbackPlacement Rollback);
|
||||||
|
|
||||||
|
private readonly record struct RollbackPlacement(
|
||||||
|
Vector3 Position,
|
||||||
|
uint CellId,
|
||||||
|
Vector3 CellLocalPosition,
|
||||||
|
Quaternion Orientation,
|
||||||
|
TransientStateFlags TransientState,
|
||||||
|
Plane ContactPlane,
|
||||||
|
bool ContactPlaneValid,
|
||||||
|
uint ContactPlaneCellId,
|
||||||
|
bool ContactPlaneIsWater,
|
||||||
|
double LastUpdateTime,
|
||||||
|
bool Airborne,
|
||||||
|
Vector3 LastServerPosition,
|
||||||
|
double LastServerPositionTime,
|
||||||
|
Vector3 LastShadowSyncPosition);
|
||||||
|
|
||||||
|
internal Result TryApply(
|
||||||
|
ILiveEntityRemotePlacementRuntime remote,
|
||||||
|
WorldEntity entity,
|
||||||
|
Vector3 requestedWorldPosition,
|
||||||
|
uint requestedCellId,
|
||||||
|
Vector3 requestedCellLocalPosition,
|
||||||
|
Quaternion requestedOrientation,
|
||||||
|
double gameTime,
|
||||||
|
bool destinationProjectionVisible,
|
||||||
|
ushort generation,
|
||||||
|
ushort positionSequence)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(remote);
|
||||||
|
ArgumentNullException.ThrowIfNull(entity);
|
||||||
|
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord)
|
||||||
|
|| liveRecord.Generation != generation
|
||||||
|
|| liveRecord.PhysicsBody is null
|
||||||
|
|| !ReferenceEquals(liveRecord.PhysicsBody, remote.Body))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Remote placement for 0x{entity.ServerGuid:X8} must use its incarnation's canonical physics body.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wasInContact = liveRecord.PhysicsBody.InContact;
|
||||||
|
bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable;
|
||||||
|
RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody);
|
||||||
|
if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior)
|
||||||
|
&& prior.Generation == generation)
|
||||||
|
{
|
||||||
|
wasInContact = prior.WasInContact;
|
||||||
|
wasOnWalkable = prior.WasOnWalkable;
|
||||||
|
rollback = prior.Rollback;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingPlacement request = new(
|
||||||
|
remote,
|
||||||
|
liveRecord.PhysicsBody,
|
||||||
|
entity,
|
||||||
|
requestedWorldPosition,
|
||||||
|
requestedCellId,
|
||||||
|
requestedOrientation,
|
||||||
|
gameTime,
|
||||||
|
generation,
|
||||||
|
positionSequence,
|
||||||
|
wasInContact,
|
||||||
|
wasOnWalkable,
|
||||||
|
rollback);
|
||||||
|
if (!destinationProjectionVisible)
|
||||||
|
{
|
||||||
|
ParkPending(request, requestedCellLocalPosition);
|
||||||
|
return new Result(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
requestedWorldPosition,
|
||||||
|
requestedCellId,
|
||||||
|
requestedOrientation);
|
||||||
|
}
|
||||||
|
ResolveResult placement = Resolve(request);
|
||||||
|
if (!placement.Ok)
|
||||||
|
{
|
||||||
|
_pending.Remove(entity.ServerGuid);
|
||||||
|
RollBackDeferredPlacement(request);
|
||||||
|
return new Result(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
request.Body.Position,
|
||||||
|
remote.CellId,
|
||||||
|
request.Body.Orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pending.Remove(entity.ServerGuid);
|
||||||
|
return CommitResolved(request, placement);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Forget(uint serverGuid)
|
||||||
|
{
|
||||||
|
_pending.Remove(serverGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Clear()
|
||||||
|
{
|
||||||
|
_pending.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool HasPending(uint serverGuid) => _pending.ContainsKey(serverGuid);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transfers any older deferred shadow restoration before the accepted
|
||||||
|
/// destination is rebucketed and can publish a visibility edge.
|
||||||
|
/// </summary>
|
||||||
|
internal void BeginPlacement(uint serverGuid, ushort generation) =>
|
||||||
|
_beginAuthoritativePlacement(serverGuid, generation);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
|
||||||
|
Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolveResult Resolve(PendingPlacement request)
|
||||||
|
{
|
||||||
|
var (radius, height) = _getSetupCylinder(
|
||||||
|
request.Entity.ServerGuid,
|
||||||
|
request.Entity);
|
||||||
|
if (radius < 0.05f)
|
||||||
|
{
|
||||||
|
radius = 0.48f;
|
||||||
|
height = 1.835f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _resolvePlacement(
|
||||||
|
request.RequestedWorldPosition,
|
||||||
|
request.RequestedCellId,
|
||||||
|
radius,
|
||||||
|
height,
|
||||||
|
IsPlayerGuid(request.Entity.ServerGuid)
|
||||||
|
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
|
||||||
|
: ObjectInfoState.EdgeSlide,
|
||||||
|
request.Entity.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolveResult ResolvePlacement(
|
||||||
|
Vector3 position,
|
||||||
|
uint cellId,
|
||||||
|
float radius,
|
||||||
|
float height,
|
||||||
|
ObjectInfoState moverFlags,
|
||||||
|
uint movingEntityId) =>
|
||||||
|
_physics.ResolvePlacement(
|
||||||
|
position,
|
||||||
|
cellId,
|
||||||
|
radius,
|
||||||
|
height,
|
||||||
|
stepUpHeight: 0.4f,
|
||||||
|
stepDownHeight: 0.4f,
|
||||||
|
moverFlags: moverFlags,
|
||||||
|
movingEntityId: movingEntityId);
|
||||||
|
|
||||||
|
private Result CommitResolved(PendingPlacement request, ResolveResult placement)
|
||||||
|
{
|
||||||
|
RemoteTeleportPlacement.Apply(
|
||||||
|
request.Remote,
|
||||||
|
request.Body,
|
||||||
|
placement,
|
||||||
|
_cellLocalForSeed(placement.Position, placement.CellId),
|
||||||
|
request.RequestedOrientation,
|
||||||
|
request.GameTime,
|
||||||
|
request.WasInContact,
|
||||||
|
request.WasOnWalkable);
|
||||||
|
if (request.Remote.CellId != placement.CellId)
|
||||||
|
request.Remote.CellId = placement.CellId;
|
||||||
|
request.Remote.LastServerPosition = placement.Position;
|
||||||
|
request.Remote.LastServerPositionTime = request.GameTime;
|
||||||
|
request.Remote.LastShadowSyncPosition = Vector3.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))
|
||||||
|
{
|
||||||
|
bool deferShadowRestore =
|
||||||
|
record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|
||||||
|
|| !record.IsSpatiallyVisible;
|
||||||
|
_completeAuthoritativePlacement(
|
||||||
|
request.Entity.ServerGuid,
|
||||||
|
request.Generation,
|
||||||
|
deferShadowRestore);
|
||||||
|
if (!deferShadowRestore)
|
||||||
|
{
|
||||||
|
request.Remote.LastShadowSyncPosition = request.Body.Position;
|
||||||
|
_syncResolvedShadow(request.Entity, request.Body, placement.CellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Result(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
request.Body.Position,
|
||||||
|
placement.CellId,
|
||||||
|
request.Body.Orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
|
||||||
|
{
|
||||||
|
request.Body.Orientation = request.RequestedOrientation;
|
||||||
|
request.Body.SnapToCell(
|
||||||
|
request.RequestedCellId,
|
||||||
|
request.RequestedWorldPosition,
|
||||||
|
requestedCellLocalPosition);
|
||||||
|
request.Body.LastUpdateTime = request.GameTime;
|
||||||
|
request.Body.ContactPlaneValid = false;
|
||||||
|
request.Body.ContactPlaneCellId = 0u;
|
||||||
|
request.Body.ContactPlaneIsWater = false;
|
||||||
|
PhysicsObjUpdate.ApplySetPositionContact(
|
||||||
|
request.Body,
|
||||||
|
inContact: false,
|
||||||
|
onWalkable: false);
|
||||||
|
request.Remote.Airborne = true;
|
||||||
|
if (request.Remote.CellId != request.RequestedCellId)
|
||||||
|
request.Remote.CellId = request.RequestedCellId;
|
||||||
|
request.Remote.LastServerPosition = request.RequestedWorldPosition;
|
||||||
|
request.Remote.LastServerPositionTime = request.GameTime;
|
||||||
|
request.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||||
|
request.Entity.SetPosition(request.RequestedWorldPosition);
|
||||||
|
request.Entity.ParentCellId = request.RequestedCellId;
|
||||||
|
request.Entity.Rotation = request.RequestedOrientation;
|
||||||
|
_pending[request.Entity.ServerGuid] = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||||
|
{
|
||||||
|
if (!visible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending))
|
||||||
|
{
|
||||||
|
if (record.WorldEntity is null
|
||||||
|
|| !ReferenceEquals(record.WorldEntity, pending.Entity)
|
||||||
|
|| record.Generation != pending.Generation)
|
||||||
|
{
|
||||||
|
_pending.Remove(record.ServerGuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (record.RemoteMotionRuntime is not ILiveEntityRemotePlacementRuntime currentRemote
|
||||||
|
|| record.PhysicsBody is null
|
||||||
|
|| !ReferenceEquals(record.PhysicsBody, pending.Body)
|
||||||
|
|| !ReferenceEquals(currentRemote.Body, record.PhysicsBody))
|
||||||
|
{
|
||||||
|
_pending.Remove(record.ServerGuid);
|
||||||
|
if (record.RemoteMotionRuntime is not null
|
||||||
|
&& (record.PhysicsBody is null
|
||||||
|
|| !ReferenceEquals(record.RemoteMotionRuntime.Body, record.PhysicsBody)))
|
||||||
|
{
|
||||||
|
_liveEntities.ClearRemoteMotionRuntime(record.ServerGuid);
|
||||||
|
}
|
||||||
|
RollBackDeferredPlacement(pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ReferenceEquals(currentRemote, pending.Remote))
|
||||||
|
{
|
||||||
|
pending = pending with { Remote = currentRemote };
|
||||||
|
_pending[record.ServerGuid] = pending;
|
||||||
|
}
|
||||||
|
if (record.Snapshot.PositionSequence != pending.PositionSequence)
|
||||||
|
{
|
||||||
|
// A newer accepted UpdatePosition rebucketed the projection
|
||||||
|
// before its TryApply call could replace this request. Keep
|
||||||
|
// the original rollback alive for that synchronous call.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pending.Remove(record.ServerGuid);
|
||||||
|
ResolveResult placement = Resolve(pending);
|
||||||
|
if (!placement.Ok)
|
||||||
|
RollBackDeferredPlacement(pending);
|
||||||
|
else
|
||||||
|
CommitResolved(pending, placement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RollbackPlacement CaptureRollback(
|
||||||
|
ILiveEntityRemotePlacementRuntime remote,
|
||||||
|
PhysicsBody body)
|
||||||
|
{
|
||||||
|
return new RollbackPlacement(
|
||||||
|
body.Position,
|
||||||
|
body.CellPosition.ObjCellId,
|
||||||
|
body.CellPosition.Frame.Origin,
|
||||||
|
body.Orientation,
|
||||||
|
body.TransientState,
|
||||||
|
body.ContactPlane,
|
||||||
|
body.ContactPlaneValid,
|
||||||
|
body.ContactPlaneCellId,
|
||||||
|
body.ContactPlaneIsWater,
|
||||||
|
body.LastUpdateTime,
|
||||||
|
remote.Airborne,
|
||||||
|
remote.LastServerPosition,
|
||||||
|
remote.LastServerPositionTime,
|
||||||
|
remote.LastShadowSyncPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RollBackDeferredPlacement(PendingPlacement pending)
|
||||||
|
{
|
||||||
|
RollbackPlacement rollback = pending.Rollback;
|
||||||
|
PhysicsBody body = pending.Body;
|
||||||
|
body.Orientation = rollback.Orientation;
|
||||||
|
body.SnapToCell(
|
||||||
|
rollback.CellId,
|
||||||
|
rollback.Position,
|
||||||
|
rollback.CellLocalPosition);
|
||||||
|
body.TransientState = rollback.TransientState;
|
||||||
|
body.ContactPlane = rollback.ContactPlane;
|
||||||
|
body.ContactPlaneValid = rollback.ContactPlaneValid;
|
||||||
|
body.ContactPlaneCellId = rollback.ContactPlaneCellId;
|
||||||
|
body.ContactPlaneIsWater = rollback.ContactPlaneIsWater;
|
||||||
|
body.LastUpdateTime = rollback.LastUpdateTime;
|
||||||
|
body.calc_acceleration();
|
||||||
|
pending.Remote.Airborne = rollback.Airborne;
|
||||||
|
pending.Remote.CellId = rollback.CellId;
|
||||||
|
pending.Remote.LastServerPosition = rollback.LastServerPosition;
|
||||||
|
pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime;
|
||||||
|
pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition;
|
||||||
|
pending.Entity.SetPosition(rollback.Position);
|
||||||
|
pending.Entity.ParentCellId = rollback.CellId;
|
||||||
|
pending.Entity.Rotation = rollback.Orientation;
|
||||||
|
|
||||||
|
if (rollback.CellId == 0)
|
||||||
|
_liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid);
|
||||||
|
else
|
||||||
|
_liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId);
|
||||||
|
|
||||||
|
if (!_liveEntities.TryGetRecord(
|
||||||
|
pending.Entity.ServerGuid,
|
||||||
|
out LiveEntityRecord restored)
|
||||||
|
|| restored.Generation != pending.Generation)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rollback.CellId == 0)
|
||||||
|
{
|
||||||
|
_completeAuthoritativePlacement(
|
||||||
|
pending.Entity.ServerGuid,
|
||||||
|
pending.Generation,
|
||||||
|
false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool deferShadowRestore =
|
||||||
|
restored.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|
||||||
|
|| !restored.IsSpatiallyVisible;
|
||||||
|
_completeAuthoritativePlacement(
|
||||||
|
pending.Entity.ServerGuid,
|
||||||
|
pending.Generation,
|
||||||
|
deferShadowRestore);
|
||||||
|
if (deferShadowRestore)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pending.Remote.LastShadowSyncPosition = Vector3.Zero;
|
||||||
|
_syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPlayerGuid(uint guid) =>
|
||||||
|
(guid & 0xFF000000u) == 0x50000000u;
|
||||||
|
}
|
||||||
39
src/AcDream.App/Physics/RemoteTeleportHook.cs
Normal file
39
src/AcDream.App/Physics/RemoteTeleportHook.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ordered App seam for retail <c>CPhysicsObj::teleport_hook</c>
|
||||||
|
/// (<c>0x00514ED0</c>). The action bundle keeps the port independently
|
||||||
|
/// testable while the concrete movement/interpolation/target owners remain
|
||||||
|
/// in the composition root.
|
||||||
|
/// </summary>
|
||||||
|
public static class RemoteTeleportHook
|
||||||
|
{
|
||||||
|
private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu;
|
||||||
|
|
||||||
|
public static void Execute(RemoteTeleportHookActions actions)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(actions.CancelMoveTo);
|
||||||
|
ArgumentNullException.ThrowIfNull(actions.UnStick);
|
||||||
|
ArgumentNullException.ThrowIfNull(actions.StopInterpolating);
|
||||||
|
ArgumentNullException.ThrowIfNull(actions.UnConstrain);
|
||||||
|
ArgumentNullException.ThrowIfNull(actions.NotifyTeleported);
|
||||||
|
ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd);
|
||||||
|
|
||||||
|
actions.CancelMoveTo(TeleportCancelContext);
|
||||||
|
actions.UnStick();
|
||||||
|
actions.StopInterpolating();
|
||||||
|
actions.UnConstrain();
|
||||||
|
actions.NotifyTeleported();
|
||||||
|
actions.ReportCollisionEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RemoteTeleportHookActions(
|
||||||
|
Action<WeenieError> CancelMoveTo,
|
||||||
|
Action UnStick,
|
||||||
|
Action StopInterpolating,
|
||||||
|
Action UnConstrain,
|
||||||
|
Action NotifyTeleported,
|
||||||
|
Action ReportCollisionEnd);
|
||||||
77
src/AcDream.App/Physics/RemoteTeleportPlacement.cs
Normal file
77
src/AcDream.App/Physics/RemoteTeleportPlacement.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Commits the placement half of retail <c>CPhysicsObj::MoveOrTeleport</c>
|
||||||
|
/// Branch A (<c>0x00516330</c>). The caller runs
|
||||||
|
/// <see cref="RemoteTeleportHook"/> first, then this exact frame/cell snap,
|
||||||
|
/// before considering contact-driven interpolation.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RemoteTeleportPlacement
|
||||||
|
{
|
||||||
|
internal static void Apply(
|
||||||
|
ILiveEntityRemotePlacementRuntime remote,
|
||||||
|
PhysicsBody body,
|
||||||
|
ResolveResult placement,
|
||||||
|
Vector3 cellLocalPosition,
|
||||||
|
Quaternion orientation,
|
||||||
|
double gameTime,
|
||||||
|
bool previousContact,
|
||||||
|
bool previousOnWalkable)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(remote);
|
||||||
|
ArgumentNullException.ThrowIfNull(body);
|
||||||
|
if (!placement.Ok
|
||||||
|
|| !IsFinite(placement.Position)
|
||||||
|
|| !IsFinite(cellLocalPosition)
|
||||||
|
|| !PositionFrameValidation.IsValid(
|
||||||
|
placement.CellId,
|
||||||
|
cellLocalPosition,
|
||||||
|
orientation)
|
||||||
|
|| !double.IsFinite(gameTime))
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(placement),
|
||||||
|
"A remote teleport placement requires an accepted finite frame, clock, and nonzero cell.");
|
||||||
|
}
|
||||||
|
|
||||||
|
body.Orientation = orientation;
|
||||||
|
body.SnapToCell(placement.CellId, placement.Position, cellLocalPosition);
|
||||||
|
body.LastUpdateTime = gameTime;
|
||||||
|
|
||||||
|
body.ContactPlaneValid = placement.InContact;
|
||||||
|
if (placement.InContact)
|
||||||
|
{
|
||||||
|
body.ContactPlane = placement.ContactPlane;
|
||||||
|
body.ContactPlaneCellId = placement.ContactPlaneCellId;
|
||||||
|
body.ContactPlaneIsWater = placement.ContactPlaneIsWater;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
body.ContactPlaneCellId = 0u;
|
||||||
|
body.ContactPlaneIsWater = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||||
|
body,
|
||||||
|
placement.InContact,
|
||||||
|
placement.OnWalkable,
|
||||||
|
placement.CollisionNormalValid,
|
||||||
|
placement.CollisionNormal,
|
||||||
|
previousContact,
|
||||||
|
previousOnWalkable,
|
||||||
|
remote.HitGround,
|
||||||
|
remote.LeaveGround);
|
||||||
|
remote.Airborne = !body.OnWalkable;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsFinite(Vector3 value) =>
|
||||||
|
float.IsFinite(value.X)
|
||||||
|
&& float.IsFinite(value.Y)
|
||||||
|
&& float.IsFinite(value.Z);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -135,6 +135,19 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
|
||||||
/// registry.</summary>
|
/// registry.</summary>
|
||||||
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Hidden object remains logically alive but is removed from the cell
|
||||||
|
/// lookup/interaction surface. Retail's DetectionManager sends
|
||||||
|
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
|
||||||
|
/// not ported yet, so the App bridges the same unavailability edge through
|
||||||
|
/// the existing target fan-out. The non-Ok update tears down watcher
|
||||||
|
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
|
||||||
|
/// UnHide re-subscription receives a fresh initial snapshot. The object's
|
||||||
|
/// own watcher role is deliberately preserved.
|
||||||
|
/// </summary>
|
||||||
|
public void NotifyHidden() =>
|
||||||
|
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
|
||||||
|
|
||||||
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
||||||
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
||||||
/// (drop this entity's OWN subscription) then
|
/// (drop this entity's OWN subscription) then
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability);
|
child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability);
|
||||||
if (!ApplyParentWorldPose(child.Entity, parentWorld))
|
if (!ApplyParentWorldPose(child.Entity, parentWorld))
|
||||||
return false;
|
return false;
|
||||||
|
ApplyParentDrawVisibility(child.Entity, parent);
|
||||||
child.Entity.ParentCellId = parent.ParentCellId;
|
child.Entity.ParentCellId = parent.ParentCellId;
|
||||||
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
|
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
|
||||||
if (parent.ParentCellId is { } parentCellId)
|
if (parent.ParentCellId is { } parentCellId)
|
||||||
|
|
@ -317,6 +318,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
return false;
|
return false;
|
||||||
ApplyParentWorldPose(entity, parentWorld);
|
ApplyParentWorldPose(entity, parentWorld);
|
||||||
|
ApplyParentDrawVisibility(entity, parentEntity);
|
||||||
entity.ParentCellId = parentCellId;
|
entity.ParentCellId = parentCellId;
|
||||||
entity.ApplyAppearance(
|
entity.ApplyAppearance(
|
||||||
pose.AttachedParts,
|
pose.AttachedParts,
|
||||||
|
|
@ -338,6 +340,14 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
pose.AttachedParts,
|
pose.AttachedParts,
|
||||||
scale,
|
scale,
|
||||||
entity);
|
entity);
|
||||||
|
if (_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord)
|
||||||
|
&& (parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
|
||||||
|
{
|
||||||
|
// A child attached while its parent is already Hidden inherits
|
||||||
|
// the same direct child NoDraw mutation retail applies from
|
||||||
|
// CPhysicsObj::set_hidden (0x00514C60).
|
||||||
|
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
|
||||||
|
}
|
||||||
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||||
|
|
@ -376,6 +386,18 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Preserve retail attachment-tree draw inheritance after adapting every
|
||||||
|
/// child CPhysicsObj into an independent retained draw entry. This does not
|
||||||
|
/// alter the child's own NoDraw state; parent-first ticking propagates an
|
||||||
|
/// ancestor's suppression through arbitrarily deep attachment chains.
|
||||||
|
/// </summary>
|
||||||
|
internal static void ApplyParentDrawVisibility(WorldEntity child, WorldEntity parent)
|
||||||
|
{
|
||||||
|
child.IsAncestorDrawVisible =
|
||||||
|
parent.IsDrawVisible && parent.IsAncestorDrawVisible;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryDecomposeWorldPose(
|
private static bool TryDecomposeWorldPose(
|
||||||
Matrix4x4 world,
|
Matrix4x4 world,
|
||||||
out Vector3 position,
|
out Vector3 position,
|
||||||
|
|
@ -425,6 +447,20 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CPhysicsObj::set_hidden</c> walks the direct CHILDLIST and
|
||||||
|
/// sets/clears each child's NoDraw bit before changing root collision/cell
|
||||||
|
/// presentation. Descendants are not recursively rewritten here.
|
||||||
|
/// </summary>
|
||||||
|
public void SetDirectChildrenNoDraw(uint parentGuid, bool noDraw)
|
||||||
|
{
|
||||||
|
foreach (AttachedChild child in _attachedByChild.Values)
|
||||||
|
{
|
||||||
|
if (child.ParentGuid == parentGuid)
|
||||||
|
_liveEntities.SetAttachedChildNoDraw(child.ChildGuid, noDraw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ResolveRelations(uint childGuid)
|
private void ResolveRelations(uint childGuid)
|
||||||
{
|
{
|
||||||
Relations.Resolve(
|
Relations.Resolve(
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// once the injected shared helpers exist as method groups (GetSetupCylinder,
|
// once the injected shared helpers exist as method groups (GetSetupCylinder,
|
||||||
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
|
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
|
||||||
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
|
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
|
||||||
|
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||||
// Step 7 projectile presentation. The controller owns no identity map;
|
// Step 7 projectile presentation. The controller owns no identity map;
|
||||||
// each runtime component is stored on the canonical LiveEntityRecord.
|
// each runtime component is stored on the canonical LiveEntityRecord.
|
||||||
private AcDream.App.Physics.ProjectileController? _projectileController;
|
private AcDream.App.Physics.ProjectileController? _projectileController;
|
||||||
|
|
@ -460,10 +461,9 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RemoteMotion :
|
internal sealed class RemoteMotion :
|
||||||
AcDream.App.World.ILiveEntityRemoteMotionRuntime,
|
AcDream.App.World.ILiveEntityRemotePlacementRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||||||
AcDream.App.World.ILiveEntityCanonicalCellConsumer // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
|
||||||
{
|
{
|
||||||
public AcDream.Core.Physics.PhysicsBody Body;
|
public AcDream.Core.Physics.PhysicsBody Body { get; }
|
||||||
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
|
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
|
||||||
|
|
||||||
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
|
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
|
||||||
|
|
@ -613,6 +613,36 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition
|
||||||
|
{
|
||||||
|
get => LastServerPos;
|
||||||
|
set => LastServerPos = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime
|
||||||
|
{
|
||||||
|
get => LastServerPosTime;
|
||||||
|
set => LastServerPosTime = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition
|
||||||
|
{
|
||||||
|
get => LastShadowSyncPos;
|
||||||
|
set => LastShadowSyncPos = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne
|
||||||
|
{
|
||||||
|
get => Airborne;
|
||||||
|
set => Airborne = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() =>
|
||||||
|
Movement.HitGround();
|
||||||
|
|
||||||
|
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() =>
|
||||||
|
Motion.LeaveGround();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
|
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
|
||||||
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with
|
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with
|
||||||
|
|
@ -857,6 +887,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// from the animation pipeline flip the matching LightSource.IsLit.
|
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||||||
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
|
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
|
||||||
|
private AcDream.App.World.LiveEntityPresentationController? _liveEntityPresentation;
|
||||||
|
|
||||||
// #188 — TransparentPartHook fires from the animation pipeline drive
|
// #188 — TransparentPartHook fires from the animation pipeline drive
|
||||||
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
|
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
|
||||||
|
|
@ -2173,7 +2204,8 @@ public sealed class GameWindow : IDisposable
|
||||||
playerCellId: () => _playerController?.CellId ?? 0u,
|
playerCellId: () => _playerController?.CellId ?? 0u,
|
||||||
selectedGuid: () => _selection.SelectedObjectId,
|
selectedGuid: () => _selection.SelectedObjectId,
|
||||||
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
||||||
uiLocked: () => _persistedGameplay.LockUI);
|
uiLocked: () => _persistedGameplay.LockUI,
|
||||||
|
playerEntities: _entitiesByServerGuid);
|
||||||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||||||
_retailChatVm = retailChatVm;
|
_retailChatVm = retailChatVm;
|
||||||
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
|
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
|
||||||
|
|
@ -2444,7 +2476,6 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
|
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
|
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
_effectPoses,
|
_effectPoses,
|
||||||
|
|
@ -2479,9 +2510,34 @@ public sealed class GameWindow : IDisposable
|
||||||
_entityEffects = entityEffects;
|
_entityEffects = entityEffects;
|
||||||
entityEffects.DiagnosticSink = message =>
|
entityEffects.DiagnosticSink = message =>
|
||||||
Console.Error.WriteLine($"vfx: {message}");
|
Console.Error.WriteLine($"vfx: {message}");
|
||||||
|
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
|
||||||
|
_liveEntities,
|
||||||
|
_physicsEngine.ShadowObjects,
|
||||||
|
entityEffects.PlayTyped,
|
||||||
|
_equippedChildRenderer.SetDirectChildrenNoDraw,
|
||||||
|
ClearTargetForHiddenEntity,
|
||||||
|
() => (_liveCenterX, _liveCenterY));
|
||||||
|
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
|
||||||
|
_physicsEngine,
|
||||||
|
_liveEntities,
|
||||||
|
GetSetupCylinder,
|
||||||
|
CellLocalForSeed,
|
||||||
|
(entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||||
|
entity.Id,
|
||||||
|
remote,
|
||||||
|
_liveCenterX,
|
||||||
|
_liveCenterY,
|
||||||
|
cellId),
|
||||||
|
(guid, generation, deferShadowRestore) =>
|
||||||
|
_liveEntityPresentation.CompleteAuthoritativePlacement(
|
||||||
|
guid,
|
||||||
|
generation,
|
||||||
|
deferShadowRestore),
|
||||||
|
(guid, generation) =>
|
||||||
|
_liveEntityPresentation.BeginAuthoritativePlacement(guid, generation));
|
||||||
_equippedChildRenderer.EntityReady += guid =>
|
_equippedChildRenderer.EntityReady += guid =>
|
||||||
{
|
{
|
||||||
entityEffects.OnLiveEntityReady(guid);
|
CompleteLiveEntityReady(guid);
|
||||||
};
|
};
|
||||||
_equippedChildRenderer.ProjectionPoseReady += guid =>
|
_equippedChildRenderer.ProjectionPoseReady += guid =>
|
||||||
_liveEntityLights.OnAttachedPoseReady(guid);
|
_liveEntityLights.OnAttachedPoseReady(guid);
|
||||||
|
|
@ -2704,16 +2760,33 @@ public sealed class GameWindow : IDisposable
|
||||||
_equippedChildRenderer?.Clear();
|
_equippedChildRenderer?.Clear();
|
||||||
Objects.Clear();
|
Objects.Clear();
|
||||||
_selection.Reset();
|
_selection.Reset();
|
||||||
|
_pendingPostArrivalAction = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_liveEntities?.Clear();
|
_liveEntities?.Clear();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// F754/F755 can precede CreateObject, so the mixed pending FIFO
|
try
|
||||||
// may contain owners with no LiveEntityRecord to tear down.
|
{
|
||||||
_entityEffects?.ClearNetworkState();
|
// A pending teleport is operational state outside the live
|
||||||
_animationHookFrames?.Clear();
|
// record. Clear it independently even if a resource callback
|
||||||
|
// failed while the canonical runtime was draining.
|
||||||
|
_remoteTeleportController?.Clear();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// F754/F755 can precede CreateObject, so the mixed pending FIFO
|
||||||
|
// may contain owners with no LiveEntityRecord to tear down.
|
||||||
|
_entityEffects?.ClearNetworkState();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_animationHookFrames?.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4096,7 +4169,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// Renderer, script owner, optional animation owner, collision body,
|
// Renderer, script owner, optional animation owner, collision body,
|
||||||
// and effect profile are now all installed. Only at this boundary may
|
// and effect profile are now all installed. Only at this boundary may
|
||||||
// the mixed pre-Create F754/F755 FIFO replay against the local ID.
|
// the mixed pre-Create F754/F755 FIFO replay against the local ID.
|
||||||
_entityEffects?.OnLiveEntityReady(spawn.Guid);
|
CompleteLiveEntityReady(spawn.Guid);
|
||||||
|
|
||||||
// Dump a summary periodically so we can see drop breakdowns without
|
// Dump a summary periodically so we can see drop breakdowns without
|
||||||
// waiting for a graceful shutdown.
|
// waiting for a graceful shutdown.
|
||||||
|
|
@ -4468,7 +4541,9 @@ public sealed class GameWindow : IDisposable
|
||||||
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
|
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
|
||||||
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
|
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
|
||||||
|
|
||||||
uint state = spawn.PhysicsState ?? 0u;
|
uint state = _liveEntities?.TryGetRecord(spawn.Guid, out LiveEntityRecord liveRecord) == true
|
||||||
|
? (uint)liveRecord.FinalPhysicsState
|
||||||
|
: spawn.PhysicsState ?? 0u;
|
||||||
|
|
||||||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||||||
entityId: entity.Id,
|
entityId: entity.Id,
|
||||||
|
|
@ -4596,6 +4671,13 @@ public sealed class GameWindow : IDisposable
|
||||||
uint cellId,
|
uint cellId,
|
||||||
bool force = false)
|
bool force = false)
|
||||||
{
|
{
|
||||||
|
if (_liveEntities?.IsHidden(_playerServerGuid) == true)
|
||||||
|
{
|
||||||
|
_physicsEngine.ShadowObjects.Suspend(playerEntity.Id);
|
||||||
|
_lastLocalPlayerShadow = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!force
|
if (!force
|
||||||
&& _lastLocalPlayerShadow is { } last
|
&& _lastLocalPlayerShadow is { } last
|
||||||
&& last.CellId == cellId
|
&& last.CellId == cellId
|
||||||
|
|
@ -4836,17 +4918,20 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
|
private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
|
||||||
{
|
{
|
||||||
|
// CObjCell::hide_object removes Hidden objects from the lookup surface
|
||||||
|
// used to establish new target/voyeur relationships. Existing
|
||||||
|
// subscriptions are cleared by LiveEntityPresentationController.
|
||||||
|
if (!_visibleEntitiesByServerGuid.ContainsKey(id))
|
||||||
|
return null;
|
||||||
if (_physicsHosts.TryGetValue(id, out var existing))
|
if (_physicsHosts.TryGetValue(id, out var existing))
|
||||||
return existing;
|
return existing;
|
||||||
if (!_entitiesByServerGuid.ContainsKey(id))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||||
var minimal = new EntityPhysicsHost(
|
var minimal = new EntityPhysicsHost(
|
||||||
id,
|
id,
|
||||||
getPosition: () => new AcDream.Core.Physics.Position(
|
getPosition: () => new AcDream.Core.Physics.Position(
|
||||||
0u,
|
0u,
|
||||||
_entitiesByServerGuid.TryGetValue(id, out var e)
|
_visibleEntitiesByServerGuid.TryGetValue(id, out var e)
|
||||||
? e.Position : System.Numerics.Vector3.Zero,
|
? e.Position : System.Numerics.Vector3.Zero,
|
||||||
System.Numerics.Quaternion.Identity),
|
System.Numerics.Quaternion.Identity),
|
||||||
getVelocity: () => System.Numerics.Vector3.Zero, // static target
|
getVelocity: () => System.Numerics.Vector3.Zero, // static target
|
||||||
|
|
@ -4914,20 +4999,63 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
if (host is null)
|
if (host is null)
|
||||||
return;
|
return;
|
||||||
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var tgtEnt))
|
if (_liveEntities is not { } liveEntities
|
||||||
|
|| !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
|
||||||
return;
|
return;
|
||||||
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
|
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
|
||||||
host.PositionManager.StickTo(targetGuid, radius, height);
|
host.PositionManager.StickTo(targetGuid, radius, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes retail object construction in order: register the effect
|
||||||
|
/// owner, apply constructor/PhysicsDesc state transitions, then replay
|
||||||
|
/// SmartBox's queued F754/F755 blobs.
|
||||||
|
/// </summary>
|
||||||
|
private void CompleteLiveEntityReady(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (_entityEffects?.PrepareLiveEntityOwner(serverGuid) != true)
|
||||||
|
return;
|
||||||
|
_liveEntityPresentation?.OnLiveEntityReady(serverGuid);
|
||||||
|
_entityEffects.ReplayPendingForLiveEntity(serverGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearTargetForHiddenEntity(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||||
|
&& pendingGuid == serverGuid)
|
||||||
|
_pendingPostArrivalAction = null;
|
||||||
|
|
||||||
|
if (_selection.SelectedObjectId == serverGuid)
|
||||||
|
{
|
||||||
|
_selection.Clear(
|
||||||
|
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||||
|
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
|
||||||
|
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
||||||
|
hiddenEntityHost.NotifyHidden();
|
||||||
|
}
|
||||||
|
|
||||||
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
|
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
|
||||||
{
|
{
|
||||||
_entityEffects?.OnLiveEntityUnregistered(record);
|
uint serverGuid = record.ServerGuid;
|
||||||
|
var cleanups = new List<Action>
|
||||||
|
{
|
||||||
|
() => _liveEntityPresentation?.Forget(record),
|
||||||
|
() => _entityEffects?.OnLiveEntityUnregistered(record),
|
||||||
|
() => _remoteTeleportController?.Forget(serverGuid),
|
||||||
|
};
|
||||||
|
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
|
||||||
|
&& pendingGuid == serverGuid)
|
||||||
|
_pendingPostArrivalAction = null;
|
||||||
|
|
||||||
if (record.WorldEntity is not { } existingEntity)
|
if (record.WorldEntity is not { } existingEntity)
|
||||||
|
{
|
||||||
|
AcDream.App.World.LiveEntityTeardown.Run(cleanups);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uint serverGuid = record.ServerGuid;
|
|
||||||
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
|
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
|
||||||
// independently (r3-port-plan §4): the manager's (each pending
|
// independently (r3-port-plan §4): the manager's (each pending
|
||||||
// animation fires MotionDone(success:false) → the bound interp pops
|
// animation fires MotionDone(success:false) → the bound interp pops
|
||||||
|
|
@ -4935,9 +5063,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade
|
// MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade
|
||||||
// relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30).
|
// relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30).
|
||||||
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
|
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
|
||||||
aeGone.Sequencer?.Manager.HandleExitWorld();
|
cleanups.Add(() => aeGone.Sequencer?.Manager.HandleExitWorld());
|
||||||
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
|
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
|
||||||
rmGone.Movement.HandleExitWorld();
|
cleanups.Add(rmGone.Movement.HandleExitWorld);
|
||||||
// R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
|
// R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
|
||||||
// entity that it left the world (TargetManager::NotifyVoyeurOfEvent
|
// entity that it left the world (TargetManager::NotifyVoyeurOfEvent
|
||||||
// ExitWorld) — a watcher moving-to/stuck-to this entity drops the
|
// ExitWorld) — a watcher moving-to/stuck-to this entity drops the
|
||||||
|
|
@ -4956,31 +5084,35 @@ public sealed class GameWindow : IDisposable
|
||||||
// (ExitWorld) tells the entities watching IT. Without the first
|
// (ExitWorld) tells the entities watching IT. Without the first
|
||||||
// two, a despawning attacker leaves a dead voyeur entry on its
|
// two, a despawning attacker leaves a dead voyeur entry on its
|
||||||
// target until send-failure pruning.
|
// target until send-failure pruning.
|
||||||
ephGone.PositionManager.UnStick();
|
cleanups.Add(ephGone.PositionManager.UnStick);
|
||||||
ephGone.ClearTarget();
|
cleanups.Add(ephGone.ClearTarget);
|
||||||
ephGone.NotifyExitWorld();
|
cleanups.Add(ephGone.NotifyExitWorld);
|
||||||
}
|
}
|
||||||
_physicsHosts.Remove(serverGuid);
|
cleanups.Add(() => _physicsHosts.Remove(serverGuid));
|
||||||
_animatedEntities.Remove(existingEntity.Id);
|
cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id));
|
||||||
_classificationCache.InvalidateEntity(existingEntity.Id);
|
cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id));
|
||||||
|
|
||||||
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
||||||
// clear using the same guid the next spawn/update would use.
|
// clear using the same guid the next spawn/update would use.
|
||||||
_remoteDeadReckon.Remove(serverGuid);
|
cleanups.Add(() => _remoteDeadReckon.Remove(serverGuid));
|
||||||
_remoteLastMove.Remove(serverGuid);
|
cleanups.Add(() => _remoteLastMove.Remove(serverGuid));
|
||||||
if (_selection.SelectedObjectId == serverGuid)
|
cleanups.Add(() =>
|
||||||
{
|
{
|
||||||
_selection.Clear(
|
if (_selection.SelectedObjectId == serverGuid)
|
||||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
{
|
||||||
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
_selection.Clear(
|
||||||
}
|
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||||
|
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
_translucencyFades.ClearEntity(existingEntity.Id); // #188
|
cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
|
||||||
LeaveWorldLiveEntityRuntimeComponents(record);
|
cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record));
|
||||||
// Logical teardown ends the retained shadow payload too. The weaker
|
// Logical teardown ends the retained shadow payload too. The weaker
|
||||||
// pickup/parent path stops after Suspend so re-entry can restore it.
|
// pickup/parent path stops after Suspend so re-entry can restore it.
|
||||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id));
|
||||||
_liveEntityLights?.Forget(existingEntity.Id);
|
cleanups.Add(() => _liveEntityLights?.Forget(existingEntity.Id));
|
||||||
|
AcDream.App.World.LiveEntityTeardown.Run(cleanups);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -5075,7 +5207,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// MoveToPosition at the wire origin (§2f).
|
// MoveToPosition at the wire origin (§2f).
|
||||||
if (update.MotionState.MovementType == 6
|
if (update.MotionState.MovementType == 6
|
||||||
&& path.TargetGuid is { } tgtGuid
|
&& path.TargetGuid is { } tgtGuid
|
||||||
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
|
&& _liveEntities is { } liveMoveEntities
|
||||||
|
&& liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
|
||||||
{
|
{
|
||||||
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
||||||
ms.ObjectId = tgtGuid;
|
ms.ObjectId = tgtGuid;
|
||||||
|
|
@ -5115,7 +5248,8 @@ public sealed class GameWindow : IDisposable
|
||||||
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
||||||
if (update.MotionState.MovementType == 8
|
if (update.MotionState.MovementType == 8
|
||||||
&& turnPath.TargetGuid is { } turnTgt
|
&& turnPath.TargetGuid is { } turnTgt
|
||||||
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
|
&& _liveEntities is { } liveTurnEntities
|
||||||
|
&& liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
|
||||||
{
|
{
|
||||||
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
||||||
ms.ObjectId = turnTgt;
|
ms.ObjectId = turnTgt;
|
||||||
|
|
@ -5761,15 +5895,25 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
|
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
|
||||||
{
|
{
|
||||||
if (!_liveEntities!.TryApplyState(parsed, out _)) return;
|
if (!_liveEntities!.TryApplyState(parsed, out _, out _)) return;
|
||||||
|
|
||||||
|
if (!_liveEntities.TryGetRecord(parsed.Guid, out LiveEntityRecord record))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Retail set_state order: Lighting, NoDraw, then Hidden. The live
|
||||||
|
// runtime already committed the raw/final bits and draw visibility;
|
||||||
|
// apply the ordered owners before updating motion/collision consumers.
|
||||||
|
_liveEntityLights?.OnStateChanged(parsed.Guid);
|
||||||
|
_liveEntityPresentation?.OnStateAccepted(parsed.Guid);
|
||||||
|
|
||||||
_projectileController?.ApplyAuthoritativeState(
|
_projectileController?.ApplyAuthoritativeState(
|
||||||
parsed.Guid,
|
parsed.Guid,
|
||||||
(AcDream.Core.Physics.PhysicsStateFlags)parsed.PhysicsState,
|
record.FinalPhysicsState,
|
||||||
_physicsScriptGameTime,
|
_physicsScriptGameTime,
|
||||||
_liveCenterX,
|
_liveCenterX,
|
||||||
_liveCenterY);
|
_liveCenterY);
|
||||||
_liveEntityLights?.OnStateChanged(parsed.Guid);
|
if (parsed.Guid == _playerServerGuid)
|
||||||
|
_playerController?.ApplyPhysicsState(record.FinalPhysicsState);
|
||||||
|
|
||||||
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
|
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
|
||||||
|
|
||||||
|
|
@ -5782,11 +5926,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// ACE flipped the ETHEREAL bit.
|
// ACE flipped the ETHEREAL bit.
|
||||||
uint registryKey = entity.Id;
|
uint registryKey = entity.Id;
|
||||||
|
|
||||||
_physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState);
|
|
||||||
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
Console.WriteLine(System.FormattableString.Invariant(
|
||||||
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
|
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyServerControlledVelocityCycle(
|
private void ApplyServerControlledVelocityCycle(
|
||||||
|
|
@ -5995,6 +6137,14 @@ public sealed class GameWindow : IDisposable
|
||||||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||||
DumpMovementTruthServerEcho(update, worldPos);
|
DumpMovementTruthServerEcho(update, worldPos);
|
||||||
|
|
||||||
|
bool remoteHardTeleport = update.Guid != _playerServerGuid
|
||||||
|
&& timestamps.TeleportHookRequired;
|
||||||
|
bool remotePlacementRequired = update.Guid != _playerServerGuid
|
||||||
|
&& (remoteHardTeleport
|
||||||
|
|| _remoteTeleportController?.HasPending(update.Guid) == true);
|
||||||
|
if (remoteHardTeleport)
|
||||||
|
RunRemoteTeleportHook(update.Guid, entity.Id);
|
||||||
|
|
||||||
// Missiles reconcile the same predicted PhysicsBody in place. The
|
// Missiles reconcile the same predicted PhysicsBody in place. The
|
||||||
// timestamp gate above already rejected stale corrections; returning
|
// timestamp gate above already rejected stale corrections; returning
|
||||||
// here prevents the generic remote locomotion path from allocating a
|
// here prevents the generic remote locomotion path from allocating a
|
||||||
|
|
@ -6013,6 +6163,13 @@ public sealed class GameWindow : IDisposable
|
||||||
_liveCenterY) == true)
|
_liveCenterY) == true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (remotePlacementRequired)
|
||||||
|
{
|
||||||
|
_remoteTeleportController!.BeginPlacement(
|
||||||
|
update.Guid,
|
||||||
|
acceptedSpawn.InstanceSequence);
|
||||||
|
}
|
||||||
|
|
||||||
// Capture the pre-update render position for the soft-snap residual
|
// Capture the pre-update render position for the soft-snap residual
|
||||||
// calculation below. Assign entity.Position to the server truth up
|
// calculation below. Assign entity.Position to the server truth up
|
||||||
// front; if we then compute a snap residual, we restore the rendered
|
// front; if we then compute a snap residual, we restore the rendered
|
||||||
|
|
@ -6096,6 +6253,50 @@ public sealed class GameWindow : IDisposable
|
||||||
rmState.Body.Position = worldPos;
|
rmState.Body.Position = worldPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a
|
||||||
|
// fresh TELEPORT_TS, or the first placement of a cell-less body,
|
||||||
|
// runs teleport_hook and SetPosition(0x1012) BEFORE the contact
|
||||||
|
// test. In particular, an airborne UP cannot veto or undo this
|
||||||
|
// authoritative destination. Do not pre-clear velocity or invent
|
||||||
|
// grounded flags here: retail SetPosition derives contact from its
|
||||||
|
// transition, while MoveOrTeleport does not consume arg5/arg6.
|
||||||
|
if (remotePlacementRequired)
|
||||||
|
{
|
||||||
|
double teleportTime =
|
||||||
|
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||||
|
bool projectionVisible = _liveEntities.TryGetRecord(
|
||||||
|
update.Guid,
|
||||||
|
out LiveEntityRecord teleportRecord)
|
||||||
|
&& teleportRecord.IsSpatiallyVisible;
|
||||||
|
var placement = _remoteTeleportController!.TryApply(
|
||||||
|
rmState,
|
||||||
|
entity,
|
||||||
|
worldPos,
|
||||||
|
p.LandblockId,
|
||||||
|
new System.Numerics.Vector3(
|
||||||
|
p.PositionX,
|
||||||
|
p.PositionY,
|
||||||
|
p.PositionZ),
|
||||||
|
rot,
|
||||||
|
teleportTime,
|
||||||
|
projectionVisible,
|
||||||
|
acceptedSpawn.InstanceSequence,
|
||||||
|
acceptedSpawn.PositionSequence);
|
||||||
|
if (!placement.Applied)
|
||||||
|
{
|
||||||
|
entity.SetPosition(rmState.Body.Position);
|
||||||
|
entity.ParentCellId = rmState.CellId;
|
||||||
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
|
if (rmState.CellId != 0)
|
||||||
|
_liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.SetPosition(rmState.Body.Position);
|
||||||
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
|
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
|
||||||
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
|
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
|
||||||
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
|
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
|
||||||
|
|
@ -6284,7 +6485,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// DR tick) and for no-Sequencer players. rmState.CellId is the server
|
// DR tick) and for no-Sequencer players. rmState.CellId is the server
|
||||||
// cell adopted above (:5735). The per-tick loop keeps it current
|
// cell adopted above (:5735). The per-tick loop keeps it current
|
||||||
// between UPs (movement-gated).
|
// between UPs (movement-gated).
|
||||||
if (rmState.CellId != 0)
|
if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid))
|
||||||
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||||
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
||||||
entity.SetPosition(rmState.Body.Position);
|
entity.SetPosition(rmState.Body.Position);
|
||||||
|
|
@ -6514,7 +6715,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
|
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
|
||||||
// the per-tick loop skips). The per-tick loop keeps it current between
|
// the per-tick loop skips). The per-tick loop keeps it current between
|
||||||
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
|
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
|
||||||
if (rmState.CellId != 0)
|
if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid))
|
||||||
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||||
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
||||||
|
|
||||||
|
|
@ -8777,7 +8978,10 @@ public sealed class GameWindow : IDisposable
|
||||||
// (which runs after this block). Replaces the AP-79 player poll.
|
// (which runs after this block). Replaces the AP-79 player poll.
|
||||||
_playerHost?.HandleTargetting();
|
_playerHost?.HandleTargetting();
|
||||||
|
|
||||||
var result = _playerController.Update((float)dt, input);
|
bool localPlayerHidden = _liveEntities?.IsHidden(_playerServerGuid) == true;
|
||||||
|
var result = localPlayerHidden
|
||||||
|
? _playerController.TickHidden((float)dt)
|
||||||
|
: _playerController.Update((float)dt, input);
|
||||||
|
|
||||||
// Update the player entity's position + rotation so it renders at
|
// Update the player entity's position + rotation so it renders at
|
||||||
// the physics-resolved location each frame.
|
// the physics-resolved location each frame.
|
||||||
|
|
@ -8787,7 +8991,8 @@ public sealed class GameWindow : IDisposable
|
||||||
pe.ParentCellId = result.CellId;
|
pe.ParentCellId = result.CellId;
|
||||||
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
|
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
|
||||||
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
|
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
|
||||||
SyncLocalPlayerShadow(pe, result.CellId);
|
if (!localPlayerHidden)
|
||||||
|
SyncLocalPlayerShadow(pe, result.CellId);
|
||||||
|
|
||||||
// Move the player entity to its current landblock in GpuWorldState
|
// Move the player entity to its current landblock in GpuWorldState
|
||||||
// so it doesn't get frustum-culled when the player walks away from
|
// so it doesn't get frustum-culled when the player walks away from
|
||||||
|
|
@ -8858,7 +9063,7 @@ public sealed class GameWindow : IDisposable
|
||||||
trackedTargetPoint: trackedCombatTarget);
|
trackedTargetPoint: trackedCombatTarget);
|
||||||
|
|
||||||
// Send outbound movement messages to the live server.
|
// Send outbound movement messages to the live server.
|
||||||
if (_liveSession is not null)
|
if (_liveSession is not null && !localPlayerHidden)
|
||||||
{
|
{
|
||||||
// Convert world position back to AC wire coordinates.
|
// Convert world position back to AC wire coordinates.
|
||||||
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
|
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
|
||||||
|
|
@ -9274,6 +9479,14 @@ public sealed class GameWindow : IDisposable
|
||||||
// Re-publish without advancing so an extra render between updates still
|
// Re-publish without advancing so an extra render between updates still
|
||||||
// retains retail's animation-hook versus object-hook phase barrier.
|
// retains retail's animation-hook versus object-hook phase barrier.
|
||||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||||
|
if (_liveEntities is { } liveEntities)
|
||||||
|
{
|
||||||
|
_remotePhysicsUpdater.TickHiddenEntities(
|
||||||
|
liveEntities,
|
||||||
|
_playerServerGuid,
|
||||||
|
(float)deltaSeconds,
|
||||||
|
entity => _effectPoses.UpdateRoot(entity));
|
||||||
|
}
|
||||||
_projectileController?.Tick(
|
_projectileController?.Tick(
|
||||||
_physicsScriptGameTime,
|
_physicsScriptGameTime,
|
||||||
_liveCenterX,
|
_liveCenterX,
|
||||||
|
|
@ -10386,6 +10599,22 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RunRemoteTeleportHook(uint serverGuid, uint localEntityId)
|
||||||
|
{
|
||||||
|
_remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
|
||||||
|
EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered)
|
||||||
|
? registered as EntityPhysicsHost
|
||||||
|
: remote?.Host;
|
||||||
|
AcDream.App.Physics.RemoteTeleportHook.Execute(
|
||||||
|
new AcDream.App.Physics.RemoteTeleportHookActions(
|
||||||
|
CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
|
||||||
|
UnStick: () => host?.PositionManager.UnStick(),
|
||||||
|
StopInterpolating: () => remote?.Interp.Clear(),
|
||||||
|
UnConstrain: () => host?.PositionManager.UnConstrain(),
|
||||||
|
NotifyTeleported: () => host?.NotifyTeleported(),
|
||||||
|
ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.4: advance every animated entity's frame counter by
|
/// Phase 6.4: advance every animated entity's frame counter by
|
||||||
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
|
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
|
||||||
|
|
@ -10433,6 +10662,21 @@ public sealed class GameWindow : IDisposable
|
||||||
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
|
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
|
||||||
|
// no PartArray animation and no physics integration, but
|
||||||
|
// PositionManager::adjust_offset plus the manager tail still run.
|
||||||
|
// Scripts and particles tick in their shared passes below.
|
||||||
|
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
|
||||||
|
{
|
||||||
|
// Hidden suppresses the PartArray/mesh, not the effect owner.
|
||||||
|
// Remote PositionManager composition already ran in the
|
||||||
|
// unconditional live-entity pass; publish again for the local
|
||||||
|
// player and for retained objects without remote motion.
|
||||||
|
// Frozen part-local poses remain unchanged until UnHide.
|
||||||
|
_effectPoses.UpdateRoot(ae.Entity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||||||
// entities; without integration, remote chars jitter-hop between
|
// entities; without integration, remote chars jitter-hop between
|
||||||
|
|
@ -13570,6 +13814,8 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
|
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
|
||||||
|
if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord playerRecord) == true)
|
||||||
|
_playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
|
||||||
|
|
||||||
// R4-V5: the local player's verbatim MoveToManager — same seam
|
// R4-V5: the local player's verbatim MoveToManager — same seam
|
||||||
// wiring shape as EnsureRemoteMotionBindings, with three
|
// wiring shape as EnsureRemoteMotionBindings, with three
|
||||||
|
|
@ -14192,6 +14438,9 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
_liveEntityLights?.Dispose();
|
_liveEntityLights?.Dispose();
|
||||||
_liveEntityLights = null;
|
_liveEntityLights = null;
|
||||||
|
_liveEntityPresentation?.Dispose();
|
||||||
|
_remoteTeleportController?.Dispose();
|
||||||
|
_remoteTeleportController = null;
|
||||||
_entityEffects?.ClearNetworkState();
|
_entityEffects?.ClearNetworkState();
|
||||||
_animationHookFrames?.Clear();
|
_animationHookFrames?.Clear();
|
||||||
_effectPoses.Clear();
|
_effectPoses.Clear();
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,19 @@ public sealed class EntityEffectController : IAnimationHookSink
|
||||||
/// original mixed F754/F755 order.
|
/// original mixed F754/F755 order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool OnLiveEntityReady(uint serverGuid)
|
public bool OnLiveEntityReady(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (!PrepareLiveEntityOwner(serverGuid))
|
||||||
|
return false;
|
||||||
|
ReplayPendingForLiveEntity(serverGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the local effect owner without replaying pre-create packets.
|
||||||
|
/// Production uses this narrow barrier so constructor/set_description
|
||||||
|
/// state effects can run before SmartBox replays queued network blobs.
|
||||||
|
/// </summary>
|
||||||
|
public bool PrepareLiveEntityOwner(uint serverGuid)
|
||||||
{
|
{
|
||||||
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||||
|| record.WorldEntity is not { } entity
|
|| record.WorldEntity is not { } entity
|
||||||
|
|
@ -116,7 +129,15 @@ public sealed class EntityEffectController : IAnimationHookSink
|
||||||
_profilesByLocalId[entity.Id] = profile;
|
_profilesByLocalId[entity.Id] = profile;
|
||||||
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
_runner.SetOwnerAnchor(entity.Id, entity.Position);
|
||||||
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
|
||||||
TryReplayPending(serverGuid, entity.Id);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
|
||||||
|
public bool ReplayPendingForLiveEntity(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (!TryGetReadyLocalId(serverGuid, out uint localId))
|
||||||
|
return false;
|
||||||
|
TryReplayPending(serverGuid, localId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
foreach (var animatedId in animatedEntityIds)
|
foreach (var animatedId in animatedEntityIds)
|
||||||
{
|
{
|
||||||
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
|
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
|
||||||
|
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
|
||||||
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||||
if (!EntityMatchesSet(entity, set)) continue;
|
if (!EntityMatchesSet(entity, set)) continue;
|
||||||
if (entity.MeshRefs.Count == 0) continue;
|
if (entity.MeshRefs.Count == 0) continue;
|
||||||
|
|
@ -722,6 +723,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
|
|
||||||
foreach (var entity in entry.Entities)
|
foreach (var entity in entry.Entities)
|
||||||
{
|
{
|
||||||
|
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
|
||||||
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||||
if (!EntityMatchesSet(entity, set)) continue;
|
if (!EntityMatchesSet(entity, set)) continue;
|
||||||
if (entity.MeshRefs.Count == 0) continue;
|
if (entity.MeshRefs.Count == 0) continue;
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ public sealed class GpuWorldState
|
||||||
// list, so rebuilding it replaces the reference atomically.
|
// list, so rebuilding it replaces the reference atomically.
|
||||||
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
|
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
|
||||||
private readonly HashSet<uint> _visibleLiveGuids = new();
|
private readonly HashSet<uint> _visibleLiveGuids = new();
|
||||||
|
private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new();
|
||||||
|
private bool _dispatchingVisibilityTransitions;
|
||||||
|
|
||||||
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
|
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
|
||||||
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
|
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
|
||||||
|
|
@ -282,6 +284,10 @@ public sealed class GpuWorldState
|
||||||
// and it couldn't reach a pending entity). Re-appending routes the entity
|
// and it couldn't reach a pending entity). Re-appending routes the entity
|
||||||
// to _loaded (drawn) when its landblock is loaded, or back to pending to
|
// to _loaded (drawn) when its landblock is loaded, or back to pending to
|
||||||
// await AddLandblock otherwise.
|
// await AddLandblock otherwise.
|
||||||
|
// Remove + place is one spatial transaction. Rebuilding between the
|
||||||
|
// two operations exposes a false/true implementation pulse for a
|
||||||
|
// loaded-to-loaded move and lets reentrant visibility callbacks observe
|
||||||
|
// a state that never exists at the LiveEntityRuntime boundary.
|
||||||
RemoveEntityFromAllBuckets(entity);
|
RemoveEntityFromAllBuckets(entity);
|
||||||
PlaceLiveEntityProjection(canonical, entity);
|
PlaceLiveEntityProjection(canonical, entity);
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +313,6 @@ public sealed class GpuWorldState
|
||||||
if (j != i) newList.Add(entities[j]);
|
if (j != i) newList.Add(entities[j]);
|
||||||
_loaded[kvp.Key] = new LoadedLandblock(
|
_loaded[kvp.Key] = new LoadedLandblock(
|
||||||
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
|
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
|
||||||
RebuildFlatView();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -510,6 +515,7 @@ public sealed class GpuWorldState
|
||||||
bucket.Add(entity);
|
bucket.Add(entity);
|
||||||
if (probePersistent)
|
if (probePersistent)
|
||||||
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
|
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
|
||||||
|
RebuildFlatView();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -624,21 +630,48 @@ public sealed class GpuWorldState
|
||||||
_flatEntities
|
_flatEntities
|
||||||
.Where(entity => entity.ServerGuid != 0)
|
.Where(entity => entity.ServerGuid != 0)
|
||||||
.Select(entity => entity.ServerGuid));
|
.Select(entity => entity.ServerGuid));
|
||||||
foreach (uint guid in _visibleLiveGuids)
|
uint[] becameHidden = _visibleLiveGuids
|
||||||
{
|
.Where(guid => !nowVisible.Contains(guid))
|
||||||
if (!nowVisible.Contains(guid))
|
.ToArray();
|
||||||
LiveProjectionVisibilityChanged?.Invoke(guid, false);
|
uint[] becameVisible = nowVisible
|
||||||
}
|
.Where(guid => !_visibleLiveGuids.Contains(guid))
|
||||||
foreach (uint guid in nowVisible)
|
.ToArray();
|
||||||
{
|
|
||||||
if (!_visibleLiveGuids.Contains(guid))
|
// Commit the new spatial truth before notifying observers. A
|
||||||
LiveProjectionVisibilityChanged?.Invoke(guid, true);
|
// visibility callback may synchronously rebucket/withdraw the same
|
||||||
}
|
// object (deferred teleport rollback); those nested transitions must
|
||||||
|
// compare against this committed state rather than the stale prior set.
|
||||||
_visibleLiveGuids.Clear();
|
_visibleLiveGuids.Clear();
|
||||||
_visibleLiveGuids.UnionWith(nowVisible);
|
_visibleLiveGuids.UnionWith(nowVisible);
|
||||||
|
foreach (uint guid in becameHidden)
|
||||||
|
_visibilityTransitions.Enqueue((guid, false));
|
||||||
|
foreach (uint guid in becameVisible)
|
||||||
|
_visibilityTransitions.Enqueue((guid, true));
|
||||||
|
DrainVisibilityTransitions();
|
||||||
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
|
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrainVisibilityTransitions()
|
||||||
|
{
|
||||||
|
if (_dispatchingVisibilityTransitions)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_dispatchingVisibilityTransitions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (_visibilityTransitions.TryDequeue(out var transition))
|
||||||
|
{
|
||||||
|
LiveProjectionVisibilityChanged?.Invoke(
|
||||||
|
transition.ServerGuid,
|
||||||
|
transition.Visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dispatchingVisibilityTransitions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TEMP (#138-B): persistent guids currently present in the drawn flat
|
// TEMP (#138-B): persistent guids currently present in the drawn flat
|
||||||
// view, for EntityVanishProbe transition logging. Strip with the probe.
|
// view, for EntityVanishProbe transition logging. Strip with the probe.
|
||||||
private readonly HashSet<uint> _persistentInFlatProbe = new();
|
private readonly HashSet<uint> _persistentInFlatProbe = new();
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ public sealed class RadarSnapshotProvider
|
||||||
|
|
||||||
private readonly ClientObjectTable _objects;
|
private readonly ClientObjectTable _objects;
|
||||||
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
|
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
|
||||||
|
private readonly IReadOnlyDictionary<uint, WorldEntity> _playerEntities;
|
||||||
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
|
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
|
||||||
private readonly Func<uint> _playerGuid;
|
private readonly Func<uint> _playerGuid;
|
||||||
private readonly Func<float> _playerYawRadians;
|
private readonly Func<float> _playerYawRadians;
|
||||||
|
|
@ -39,10 +40,12 @@ public sealed class RadarSnapshotProvider
|
||||||
Func<uint?> selectedGuid,
|
Func<uint?> selectedGuid,
|
||||||
Func<bool> coordinatesOnRadar,
|
Func<bool> coordinatesOnRadar,
|
||||||
Func<bool> uiLocked,
|
Func<bool> uiLocked,
|
||||||
Func<uint, RadarRelationshipTraits>? relationshipFor = null)
|
Func<uint, RadarRelationshipTraits>? relationshipFor = null,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>? playerEntities = null)
|
||||||
{
|
{
|
||||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
|
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
|
||||||
|
_playerEntities = playerEntities ?? worldEntities;
|
||||||
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
|
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
|
||||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
|
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
|
||||||
|
|
@ -57,7 +60,7 @@ public sealed class RadarSnapshotProvider
|
||||||
{
|
{
|
||||||
bool uiLocked = _uiLocked();
|
bool uiLocked = _uiLocked();
|
||||||
uint playerGuid = _playerGuid();
|
uint playerGuid = _playerGuid();
|
||||||
if (playerGuid == 0u || !_worldEntities.TryGetValue(playerGuid, out var playerEntity))
|
if (playerGuid == 0u || !_playerEntities.TryGetValue(playerGuid, out var playerEntity))
|
||||||
return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
|
return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
|
||||||
|
|
||||||
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());
|
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,10 @@ public sealed class InboundPhysicsStateController
|
||||||
update.TeleportSequence,
|
update.TeleportSequence,
|
||||||
update.ForcePositionSequence,
|
update.ForcePositionSequence,
|
||||||
isLocalPlayer);
|
isLocalPlayer);
|
||||||
timestamps = Current(gate);
|
timestamps = Current(
|
||||||
|
gate,
|
||||||
|
teleportAdvanced: disposition is PositionTimestampDisposition.Apply
|
||||||
|
&& advancesTeleport);
|
||||||
if (disposition is PositionTimestampDisposition.Rejected)
|
if (disposition is PositionTimestampDisposition.Rejected)
|
||||||
{
|
{
|
||||||
accepted = MirrorGateTimestamps(old, gate);
|
accepted = MirrorGateTimestamps(old, gate);
|
||||||
|
|
@ -438,11 +441,14 @@ public sealed class InboundPhysicsStateController
|
||||||
0,
|
0,
|
||||||
spawn.InstanceSequence);
|
spawn.InstanceSequence);
|
||||||
|
|
||||||
private static AcceptedPhysicsTimestamps Current(PhysicsTimestampGate gate) => new(
|
private static AcceptedPhysicsTimestamps Current(
|
||||||
|
PhysicsTimestampGate gate,
|
||||||
|
bool teleportAdvanced = false) => new(
|
||||||
gate.InstanceTimestamp,
|
gate.InstanceTimestamp,
|
||||||
gate.ServerControlledMoveTimestamp,
|
gate.ServerControlledMoveTimestamp,
|
||||||
gate.TeleportTimestamp,
|
gate.TeleportTimestamp,
|
||||||
gate.ForcePositionTimestamp);
|
gate.ForcePositionTimestamp,
|
||||||
|
teleportAdvanced);
|
||||||
|
|
||||||
private static WorldSession.EntitySpawn MirrorGateTimestamps(
|
private static WorldSession.EntitySpawn MirrorGateTimestamps(
|
||||||
WorldSession.EntitySpawn spawn,
|
WorldSession.EntitySpawn spawn,
|
||||||
|
|
@ -622,7 +628,9 @@ public readonly record struct AcceptedPhysicsTimestamps(
|
||||||
ushort Instance,
|
ushort Instance,
|
||||||
ushort ServerControlledMove,
|
ushort ServerControlledMove,
|
||||||
ushort Teleport,
|
ushort Teleport,
|
||||||
ushort ForcePosition);
|
ushort ForcePosition,
|
||||||
|
bool TeleportAdvanced = false,
|
||||||
|
bool TeleportHookRequired = false);
|
||||||
|
|
||||||
public readonly record struct CreateParentUpdate(
|
public readonly record struct CreateParentUpdate(
|
||||||
uint ChildGuid,
|
uint ChildGuid,
|
||||||
|
|
|
||||||
282
src/AcDream.App/World/LiveEntityPresentationController.cs
Normal file
282
src/AcDream.App/World/LiveEntityPresentationController.cs
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the presentation/collision side effects of accepted retail
|
||||||
|
/// PhysicsState transitions after the canonical live owner is ready.
|
||||||
|
/// Logical ownership stays in <see cref="LiveEntityRuntime"/>; Hidden never
|
||||||
|
/// unregisters scripts, particles, lights, meshes, or the live record.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Ports <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>),
|
||||||
|
/// <c>set_nodraw</c> (<c>0x0050FCA0</c>), and <c>set_hidden</c>
|
||||||
|
/// (<c>0x00514C60</c>). Typed script ids are retail
|
||||||
|
/// <c>PS_UnHide=0x75</c> and <c>PS_Hidden=0x76</c> from <c>acclient.h</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LiveEntityPresentationController : IDisposable
|
||||||
|
{
|
||||||
|
public const uint UnHideScriptType = 0x75u;
|
||||||
|
public const uint HiddenScriptType = 0x76u;
|
||||||
|
|
||||||
|
private readonly LiveEntityRuntime _liveEntities;
|
||||||
|
private readonly ShadowObjectRegistry _shadows;
|
||||||
|
private readonly Func<uint, uint, float, bool> _playTyped;
|
||||||
|
private readonly Action<uint, bool> _setDirectChildrenNoDraw;
|
||||||
|
private readonly Action<uint> _clearInvalidTarget;
|
||||||
|
private readonly Func<(int X, int Y)> _liveCenter;
|
||||||
|
private readonly Action<uint>? _onShadowRestored;
|
||||||
|
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
|
||||||
|
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
|
||||||
|
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public LiveEntityPresentationController(
|
||||||
|
LiveEntityRuntime liveEntities,
|
||||||
|
ShadowObjectRegistry shadows,
|
||||||
|
Func<uint, uint, float, bool> playTyped,
|
||||||
|
Action<uint, bool>? setDirectChildrenNoDraw = null,
|
||||||
|
Action<uint>? clearInvalidTarget = null,
|
||||||
|
Func<(int X, int Y)>? liveCenter = null,
|
||||||
|
Action<uint>? onShadowRestored = null)
|
||||||
|
{
|
||||||
|
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||||
|
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||||
|
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
|
||||||
|
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
|
||||||
|
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
|
||||||
|
_liveCenter = liveCenter ?? (() => (0, 0));
|
||||||
|
_onShadowRestored = onShadowRestored;
|
||||||
|
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the state-side-effect barrier after render/effect owners have
|
||||||
|
/// registered, then drains constructor and pre-materialization transitions
|
||||||
|
/// exactly once in accepted order.
|
||||||
|
/// </summary>
|
||||||
|
public bool OnLiveEntityReady(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||||
|
|| record.WorldEntity is null
|
||||||
|
|| !record.ResourcesRegistered)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_readyGenerationByGuid[serverGuid] = record.Generation;
|
||||||
|
ApplyPendingTransitions(record);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
|
||||||
|
public bool OnStateAccepted(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||||
|
|| !_readyGenerationByGuid.TryGetValue(serverGuid, out ushort generation)
|
||||||
|
|| generation != record.Generation)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyPendingTransitions(record);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retains ownership of a collision shadow that another retail transition
|
||||||
|
/// suspended while this incarnation's spatial projection is unavailable.
|
||||||
|
/// Hidden/UnHide and teleport rollback intentionally converge on this one
|
||||||
|
/// generation-scoped restoration marker.
|
||||||
|
/// </summary>
|
||||||
|
public bool DeferShadowRestore(uint serverGuid, ushort generation)
|
||||||
|
=> CompleteAuthoritativePlacement(serverGuid, generation, deferShadowRestore: true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes active placement ownership after its final frame/cell is
|
||||||
|
/// known, either returning a stable deferred restore to presentation or
|
||||||
|
/// clearing the suspension for an immediate controller-owned restore.
|
||||||
|
/// </summary>
|
||||||
|
public bool CompleteAuthoritativePlacement(
|
||||||
|
uint serverGuid,
|
||||||
|
ushort generation,
|
||||||
|
bool deferShadowRestore)
|
||||||
|
{
|
||||||
|
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||||
|
|| record.Generation != generation
|
||||||
|
|| record.WorldEntity is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activePlacementGenerationByGuid.TryGetValue(
|
||||||
|
serverGuid,
|
||||||
|
out ushort activeGeneration)
|
||||||
|
&& activeGeneration == generation)
|
||||||
|
{
|
||||||
|
_activePlacementGenerationByGuid.Remove(serverGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deferShadowRestore)
|
||||||
|
_suspendedShadowGenerationByGuid[serverGuid] = generation;
|
||||||
|
else if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||||
|
serverGuid,
|
||||||
|
out ushort deferredGeneration)
|
||||||
|
&& deferredGeneration == generation)
|
||||||
|
_suspendedShadowGenerationByGuid.Remove(serverGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transfers a deferred restore to a newer authoritative placement before
|
||||||
|
/// that placement rebuckets and can become visible. The placement hands
|
||||||
|
/// ownership back after it reaches a stable Hidden destination/rollback;
|
||||||
|
/// this prevents an intervening UnHide from restoring an unresolved pose.
|
||||||
|
/// </summary>
|
||||||
|
public bool BeginAuthoritativePlacement(uint serverGuid, ushort generation)
|
||||||
|
{
|
||||||
|
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|
||||||
|
|| record.Generation != generation)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activePlacementGenerationByGuid[serverGuid] = generation;
|
||||||
|
if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||||
|
serverGuid,
|
||||||
|
out ushort deferredGeneration)
|
||||||
|
&& deferredGeneration == generation)
|
||||||
|
{
|
||||||
|
_suspendedShadowGenerationByGuid.Remove(serverGuid);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool HasDeferredShadowRestore(uint serverGuid) =>
|
||||||
|
_suspendedShadowGenerationByGuid.ContainsKey(serverGuid);
|
||||||
|
|
||||||
|
internal bool HasActivePlacement(uint serverGuid) =>
|
||||||
|
_activePlacementGenerationByGuid.ContainsKey(serverGuid);
|
||||||
|
|
||||||
|
public void Forget(LiveEntityRecord record)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
if (_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort generation)
|
||||||
|
&& generation == record.Generation)
|
||||||
|
{
|
||||||
|
_readyGenerationByGuid.Remove(record.ServerGuid);
|
||||||
|
}
|
||||||
|
if (_suspendedShadowGenerationByGuid.TryGetValue(
|
||||||
|
record.ServerGuid,
|
||||||
|
out ushort suspendedGeneration)
|
||||||
|
&& suspendedGeneration == record.Generation)
|
||||||
|
{
|
||||||
|
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||||
|
}
|
||||||
|
if (_activePlacementGenerationByGuid.TryGetValue(
|
||||||
|
record.ServerGuid,
|
||||||
|
out ushort activeGeneration)
|
||||||
|
&& activeGeneration == record.Generation)
|
||||||
|
{
|
||||||
|
_activePlacementGenerationByGuid.Remove(record.ServerGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_readyGenerationByGuid.Clear();
|
||||||
|
_suspendedShadowGenerationByGuid.Clear();
|
||||||
|
_activePlacementGenerationByGuid.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
|
||||||
|
Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPendingTransitions(LiveEntityRecord record)
|
||||||
|
{
|
||||||
|
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
|
||||||
|
{
|
||||||
|
if (record.WorldEntity is not { } entity)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// set_state writes the final state before any PartArray/cell side
|
||||||
|
// effect. Retained shadow registrations must see those same bits
|
||||||
|
// even while their cell rows are suspended.
|
||||||
|
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
|
||||||
|
|
||||||
|
switch (transition.HiddenTransition)
|
||||||
|
{
|
||||||
|
case RetailHiddenTransition.BecameHidden:
|
||||||
|
_playTyped(entity.Id, HiddenScriptType, 1f);
|
||||||
|
_setDirectChildrenNoDraw(record.ServerGuid, true);
|
||||||
|
_shadows.Suspend(entity.Id);
|
||||||
|
if (!IsPlacementActive(record))
|
||||||
|
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
|
||||||
|
_clearInvalidTarget(record.ServerGuid);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RetailHiddenTransition.BecameVisible:
|
||||||
|
_playTyped(entity.Id, UnHideScriptType, 1f);
|
||||||
|
_setDirectChildrenNoDraw(record.ServerGuid, false);
|
||||||
|
if (!IsPlacementActive(record) && RestoreShadow(record, entity))
|
||||||
|
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity)
|
||||||
|
{
|
||||||
|
if (!record.IsSpatiallyProjected
|
||||||
|
|| !record.IsSpatiallyVisible
|
||||||
|
|| record.FullCellId == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(int centerX, int centerY) = _liveCenter();
|
||||||
|
ShadowPositionSynchronizer.Sync(
|
||||||
|
_shadows,
|
||||||
|
entity.Id,
|
||||||
|
entity.Position,
|
||||||
|
entity.Rotation,
|
||||||
|
record.FullCellId,
|
||||||
|
centerX,
|
||||||
|
centerY);
|
||||||
|
_onShadowRestored?.Invoke(record.ServerGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
||||||
|
{
|
||||||
|
if (!visible
|
||||||
|
|| record.WorldEntity is not { } entity
|
||||||
|
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|
||||||
|
|| IsPlacementActive(record)
|
||||||
|
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|
||||||
|
|| readyGeneration != record.Generation
|
||||||
|
|| !_suspendedShadowGenerationByGuid.TryGetValue(
|
||||||
|
record.ServerGuid,
|
||||||
|
out ushort suspendedGeneration)
|
||||||
|
|| suspendedGeneration != record.Generation)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RestoreShadow(record, entity))
|
||||||
|
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPlacementActive(LiveEntityRecord record) =>
|
||||||
|
_activePlacementGenerationByGuid.TryGetValue(
|
||||||
|
record.ServerGuid,
|
||||||
|
out ushort generation)
|
||||||
|
&& generation == record.Generation;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.App.World;
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
|
|
@ -32,6 +33,24 @@ public interface ILiveEntityCanonicalCellConsumer
|
||||||
void BindCanonicalCell(Func<uint> read, Action<uint> write);
|
void BindCanonicalCell(Func<uint> read, Action<uint> write);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remote CPhysicsObj state required to finish a retail MoveOrTeleport
|
||||||
|
/// placement. A same-incarnation runtime replacement may wrap the same body,
|
||||||
|
/// but it must preserve this complete placement contract.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILiveEntityRemotePlacementRuntime :
|
||||||
|
ILiveEntityRemoteMotionRuntime,
|
||||||
|
ILiveEntityCanonicalCellConsumer
|
||||||
|
{
|
||||||
|
uint CellId { get; set; }
|
||||||
|
bool Airborne { get; set; }
|
||||||
|
Vector3 LastServerPosition { get; set; }
|
||||||
|
double LastServerPositionTime { get; set; }
|
||||||
|
Vector3 LastShadowSyncPosition { get; set; }
|
||||||
|
void HitGround();
|
||||||
|
void LeaveGround();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Projectile physics state owned by a live object.</summary>
|
/// <summary>Projectile physics state owned by a live object.</summary>
|
||||||
public interface ILiveEntityProjectileRuntime
|
public interface ILiveEntityProjectileRuntime
|
||||||
{
|
{
|
||||||
|
|
@ -83,11 +102,15 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LiveEntityRecord
|
public sealed class LiveEntityRecord
|
||||||
{
|
{
|
||||||
|
private readonly Queue<RetailPhysicsStateTransition> _pendingStateTransitions = new();
|
||||||
|
|
||||||
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
|
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
|
||||||
{
|
{
|
||||||
ServerGuid = snapshot.Guid;
|
ServerGuid = snapshot.Guid;
|
||||||
Snapshot = snapshot;
|
Snapshot = snapshot;
|
||||||
RefreshDerivedState();
|
RefreshDerivedState();
|
||||||
|
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
|
||||||
|
ApplyRawPhysicsState(RawPhysicsState);
|
||||||
}
|
}
|
||||||
|
|
||||||
public uint ServerGuid { get; }
|
public uint ServerGuid { get; }
|
||||||
|
|
@ -102,6 +125,7 @@ public sealed class LiveEntityRecord
|
||||||
public PhysicsBody? PhysicsBody { get; internal set; }
|
public PhysicsBody? PhysicsBody { get; internal set; }
|
||||||
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
||||||
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
|
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
|
||||||
|
internal bool RequiresRemotePlacementRuntime { get; set; }
|
||||||
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
|
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
|
||||||
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
|
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
|
||||||
public bool ResourcesRegistered { get; internal set; }
|
public bool ResourcesRegistered { get; internal set; }
|
||||||
|
|
@ -110,6 +134,31 @@ public sealed class LiveEntityRecord
|
||||||
public bool WorldSpawnPublished { get; internal set; }
|
public bool WorldSpawnPublished { get; internal set; }
|
||||||
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
||||||
|
|
||||||
|
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
|
||||||
|
_pendingStateTransitions.TryDequeue(out transition);
|
||||||
|
|
||||||
|
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
|
||||||
|
{
|
||||||
|
RawPhysicsState = rawState;
|
||||||
|
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
|
||||||
|
FinalPhysicsState,
|
||||||
|
(PhysicsStateFlags)rawState);
|
||||||
|
FinalPhysicsState = transition.FinalState;
|
||||||
|
if (PhysicsBody is not null)
|
||||||
|
PhysicsBody.State = FinalPhysicsState;
|
||||||
|
_pendingStateTransitions.Enqueue(transition);
|
||||||
|
return transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void SetChildNoDraw(bool noDraw)
|
||||||
|
{
|
||||||
|
FinalPhysicsState = noDraw
|
||||||
|
? FinalPhysicsState | PhysicsStateFlags.NoDraw
|
||||||
|
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
|
||||||
|
if (PhysicsBody is not null)
|
||||||
|
PhysicsBody.State = FinalPhysicsState;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cell used when a streamed landblock asks live objects to restore their
|
/// Cell used when a streamed landblock asks live objects to restore their
|
||||||
/// spatial projection. Once materialized, the canonical runtime cell wins;
|
/// spatial projection. Once materialized, the canonical runtime cell wins;
|
||||||
|
|
@ -129,7 +178,6 @@ public sealed class LiveEntityRecord
|
||||||
RawPhysicsState = Snapshot.Physics?.RawState
|
RawPhysicsState = Snapshot.Physics?.RawState
|
||||||
?? Snapshot.PhysicsState
|
?? Snapshot.PhysicsState
|
||||||
?? 0u;
|
?? 0u;
|
||||||
FinalPhysicsState = (PhysicsStateFlags)RawPhysicsState;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,8 +253,9 @@ public sealed class LiveEntityRuntime
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Currently visible top-level world projections. Attached children and
|
/// Currently visible top-level world projections. Attached children and
|
||||||
/// pending projections are excluded from radar, picking, status, and target
|
/// pending or Hidden projections are excluded from radar, picking, status,
|
||||||
/// candidate consumers.
|
/// and target candidate consumers. NoDraw alone suppresses the mesh but is
|
||||||
|
/// not a retail cell-visibility transition.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
|
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
|
||||||
_visibleWorldEntitiesByGuid;
|
_visibleWorldEntitiesByGuid;
|
||||||
|
|
@ -330,6 +379,15 @@ public sealed class LiveEntityRuntime
|
||||||
}
|
}
|
||||||
|
|
||||||
record.ProjectionKind = projectionKind;
|
record.ProjectionKind = projectionKind;
|
||||||
|
if (projectionKind is LiveEntityProjectionKind.World)
|
||||||
|
{
|
||||||
|
// Attached projections inherit the complete ancestor visibility
|
||||||
|
// chain. Once the same logical entity becomes a top-level world
|
||||||
|
// projection it has no render ancestor, so retained inherited
|
||||||
|
// suppression must be cleared at this ownership transition.
|
||||||
|
record.WorldEntity!.IsAncestorDrawVisible = true;
|
||||||
|
}
|
||||||
|
RefreshPresentation(record);
|
||||||
RebucketLiveEntity(serverGuid, fullCellId);
|
RebucketLiveEntity(serverGuid, fullCellId);
|
||||||
return record.WorldEntity;
|
return record.WorldEntity;
|
||||||
}
|
}
|
||||||
|
|
@ -362,10 +420,7 @@ public sealed class LiveEntityRuntime
|
||||||
_materializedWorldEntitiesByGuid[serverGuid] = entity;
|
_materializedWorldEntitiesByGuid[serverGuid] = entity;
|
||||||
else
|
else
|
||||||
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
||||||
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
|
RefreshPresentation(record);
|
||||||
_visibleWorldEntitiesByGuid[serverGuid] = entity;
|
|
||||||
else
|
|
||||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
|
||||||
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
|
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
|
||||||
record.FullCellId = spatialCellOrLandblockId;
|
record.FullCellId = spatialCellOrLandblockId;
|
||||||
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
|
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
|
||||||
|
|
@ -387,13 +442,38 @@ public sealed class LiveEntityRuntime
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
_spatial.RemoveLiveEntityProjection(serverGuid);
|
_spatial.RemoveLiveEntityProjection(serverGuid);
|
||||||
|
// Usually GpuWorldState delivers the false edge synchronously above.
|
||||||
|
// During a reentrant visibility callback it queues that edge until the
|
||||||
|
// outer notification completes, so publish the logical withdrawal now;
|
||||||
|
// OnSpatialVisibilityChanged rejects the later duplicate against this
|
||||||
|
// committed record state.
|
||||||
|
bool spatialEdgeWasDeferred = record.IsSpatiallyVisible;
|
||||||
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
_materializedWorldEntitiesByGuid.Remove(serverGuid);
|
||||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
||||||
record.IsSpatiallyProjected = false;
|
record.IsSpatiallyProjected = false;
|
||||||
record.IsSpatiallyVisible = false;
|
record.IsSpatiallyVisible = false;
|
||||||
|
if (spatialEdgeWasDeferred)
|
||||||
|
ProjectionVisibilityChanged?.Invoke(record, false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Withdraws a still-live projection whose authoritative rollback frame
|
||||||
|
/// is outside every world cell. Logical owners remain registered.
|
||||||
|
/// </summary>
|
||||||
|
internal bool WithdrawLiveEntityProjectionToCellless(uint serverGuid)
|
||||||
|
{
|
||||||
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||||
|
|| record.WorldEntity is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearWorldCell(serverGuid);
|
||||||
|
record.WorldEntity.ParentCellId = 0u;
|
||||||
|
return WithdrawLiveEntityProjection(serverGuid);
|
||||||
|
}
|
||||||
|
|
||||||
public bool UnregisterLiveEntity(
|
public bool UnregisterLiveEntity(
|
||||||
DeleteObject.Parsed delete,
|
DeleteObject.Parsed delete,
|
||||||
bool isLocalPlayer,
|
bool isLocalPlayer,
|
||||||
|
|
@ -449,6 +529,16 @@ public sealed class LiveEntityRuntime
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a top-level object that currently participates in picking,
|
||||||
|
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
|
||||||
|
/// Pending, attached, and Hidden projections are intentionally excluded.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetInteractionEligibleEntity(
|
||||||
|
uint serverGuid,
|
||||||
|
out WorldEntity entity) =>
|
||||||
|
_visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!);
|
||||||
|
|
||||||
public bool ContainsWorldEntity(uint serverGuid) =>
|
public bool ContainsWorldEntity(uint serverGuid) =>
|
||||||
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||||
&& record.WorldEntity is not null;
|
&& record.WorldEntity is not null;
|
||||||
|
|
@ -503,14 +593,24 @@ public sealed class LiveEntityRuntime
|
||||||
ArgumentNullException.ThrowIfNull(runtime);
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
|
||||||
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
||||||
if (record.ProjectileRuntime is { } projectile
|
PhysicsBody candidateBody = runtime.Body;
|
||||||
&& !ReferenceEquals(projectile.Body, runtime.Body))
|
if (record.PhysicsBody is { } canonicalBody
|
||||||
|
&& !ReferenceEquals(canonicalBody, candidateBody))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
|
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
|
||||||
}
|
}
|
||||||
|
if (record.RequiresRemotePlacementRuntime
|
||||||
|
&& runtime is not ILiveEntityRemotePlacementRuntime)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
|
||||||
|
}
|
||||||
|
record.RequiresRemotePlacementRuntime |=
|
||||||
|
runtime is ILiveEntityRemotePlacementRuntime;
|
||||||
record.RemoteMotionRuntime = runtime;
|
record.RemoteMotionRuntime = runtime;
|
||||||
record.PhysicsBody = runtime.Body;
|
record.PhysicsBody = candidateBody;
|
||||||
|
candidateBody.State = record.FinalPhysicsState;
|
||||||
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
|
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
|
||||||
{
|
{
|
||||||
// Capture the incarnation, not only its GUID. A callback retained
|
// Capture the incarnation, not only its GUID. A callback retained
|
||||||
|
|
@ -548,8 +648,6 @@ public sealed class LiveEntityRuntime
|
||||||
return false;
|
return false;
|
||||||
_remoteMotionByGuid.Remove(serverGuid);
|
_remoteMotionByGuid.Remove(serverGuid);
|
||||||
record.RemoteMotionRuntime = null;
|
record.RemoteMotionRuntime = null;
|
||||||
if (record.ProjectileRuntime is null)
|
|
||||||
record.PhysicsBody = null;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -562,21 +660,17 @@ public sealed class LiveEntityRuntime
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
|
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
|
||||||
}
|
}
|
||||||
if (record.RemoteMotionRuntime is { } remote
|
PhysicsBody candidateBody = runtime.Body;
|
||||||
&& !ReferenceEquals(remote.Body, runtime.Body))
|
if (record.PhysicsBody is { } canonicalBody
|
||||||
|
&& !ReferenceEquals(canonicalBody, candidateBody))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Live entity 0x{serverGuid:X8} already owns a different remote-motion physics body.");
|
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
|
||||||
}
|
|
||||||
if (record.ProjectileRuntime is { } projectile
|
|
||||||
&& !ReferenceEquals(projectile.Body, runtime.Body))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
record.ProjectileRuntime = runtime;
|
record.ProjectileRuntime = runtime;
|
||||||
record.PhysicsBody = runtime.Body;
|
record.PhysicsBody = candidateBody;
|
||||||
|
candidateBody.State = record.FinalPhysicsState;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGetProjectileRuntime(
|
public bool TryGetProjectileRuntime(
|
||||||
|
|
@ -601,8 +695,6 @@ public sealed class LiveEntityRuntime
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
record.ProjectileRuntime = null;
|
record.ProjectileRuntime = null;
|
||||||
if (record.RemoteMotionRuntime is null)
|
|
||||||
record.PhysicsBody = null;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -694,12 +786,40 @@ public sealed class LiveEntityRuntime
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
|
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||||
|
=> TryApplyState(update, out accepted, out _);
|
||||||
|
|
||||||
|
public bool TryApplyState(
|
||||||
|
SetState.Parsed update,
|
||||||
|
out WorldSession.EntitySpawn accepted,
|
||||||
|
out RetailPhysicsStateTransition transition)
|
||||||
{
|
{
|
||||||
bool applied = _inbound.TryApplyState(update, out accepted);
|
bool applied = _inbound.TryApplyState(update, out accepted);
|
||||||
if (applied) RefreshRecord(update.Guid, accepted);
|
transition = default;
|
||||||
|
if (applied)
|
||||||
|
{
|
||||||
|
RefreshRecord(update.Guid, accepted);
|
||||||
|
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
||||||
|
{
|
||||||
|
transition = record.ApplyRawPhysicsState(update.PhysicsState);
|
||||||
|
RefreshPresentation(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
return applied;
|
return applied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail parent Hidden directly toggles each attached child's NoDraw bit
|
||||||
|
/// without consuming the child's STATE_TS or replacing its raw wire state.
|
||||||
|
/// </summary>
|
||||||
|
public bool SetAttachedChildNoDraw(uint childServerGuid, bool noDraw)
|
||||||
|
{
|
||||||
|
if (!_recordsByGuid.TryGetValue(childServerGuid, out LiveEntityRecord? record))
|
||||||
|
return false;
|
||||||
|
record.SetChildNoDraw(noDraw);
|
||||||
|
RefreshPresentation(record);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public bool TryApplyPosition(
|
public bool TryApplyPosition(
|
||||||
WorldSession.EntityPositionUpdate update,
|
WorldSession.EntityPositionUpdate update,
|
||||||
bool isLocalPlayer,
|
bool isLocalPlayer,
|
||||||
|
|
@ -709,6 +829,12 @@ public sealed class LiveEntityRuntime
|
||||||
out WorldSession.EntitySpawn accepted,
|
out WorldSession.EntitySpawn accepted,
|
||||||
out AcceptedPhysicsTimestamps timestamps)
|
out AcceptedPhysicsTimestamps timestamps)
|
||||||
{
|
{
|
||||||
|
bool wasCellLess = _recordsByGuid.TryGetValue(
|
||||||
|
update.Guid,
|
||||||
|
out LiveEntityRecord? beforePosition)
|
||||||
|
&& (beforePosition.FullCellId == 0
|
||||||
|
|| !beforePosition.IsSpatiallyProjected
|
||||||
|
|| !beforePosition.IsSpatiallyVisible);
|
||||||
bool known = _inbound.TryApplyPosition(
|
bool known = _inbound.TryApplyPosition(
|
||||||
update,
|
update,
|
||||||
isLocalPlayer,
|
isLocalPlayer,
|
||||||
|
|
@ -720,6 +846,13 @@ public sealed class LiveEntityRuntime
|
||||||
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
|
||||||
{
|
{
|
||||||
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
|
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
|
||||||
|
if (disposition is PositionTimestampDisposition.Apply)
|
||||||
|
{
|
||||||
|
timestamps = timestamps with
|
||||||
|
{
|
||||||
|
TeleportHookRequired = timestamps.TeleportAdvanced || wasCellLess,
|
||||||
|
};
|
||||||
|
}
|
||||||
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
|
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
|
||||||
if (acceptedPosition)
|
if (acceptedPosition)
|
||||||
{
|
{
|
||||||
|
|
@ -734,8 +867,11 @@ public sealed class LiveEntityRuntime
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
|
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
|
||||||
/// movement/animation only while the object has visible world-cell
|
/// object tick only while the object has visible world-cell membership and
|
||||||
/// membership and is not Frozen. A pickup or parent transition keeps
|
/// is not Frozen. Hidden is deliberately not a broad tick stop: retail
|
||||||
|
/// skips PartArray animation and physics, but still applies the
|
||||||
|
/// PositionManager offset and advances targeting/movement/script/particle
|
||||||
|
/// owners. A pickup or parent transition keeps
|
||||||
/// script/effect owners but clears the cell and withdraws the root
|
/// script/effect owners but clears the cell and withdraws the root
|
||||||
/// projection until a fresh Position re-enters it.
|
/// projection until a fresh Position re-enters it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -746,6 +882,10 @@ public sealed class LiveEntityRuntime
|
||||||
&& record.FullCellId != 0
|
&& record.FullCellId != 0
|
||||||
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
|
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
|
||||||
|
|
||||||
|
public bool IsHidden(uint serverGuid) =>
|
||||||
|
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|
||||||
|
&& (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Claims the one logical top-level spawn notification for this object
|
/// Claims the one logical top-level spawn notification for this object
|
||||||
/// incarnation. Spatial re-entry must restore presentation without replaying
|
/// incarnation. Spatial re-entry must restore presentation without replaying
|
||||||
|
|
@ -832,15 +972,31 @@ public sealed class LiveEntityRuntime
|
||||||
|| record.WorldEntity is not { } entity)
|
|| record.WorldEntity is not { } entity)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
bool wasVisible = record.IsSpatiallyVisible;
|
||||||
record.IsSpatiallyVisible = visible;
|
record.IsSpatiallyVisible = visible;
|
||||||
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
|
RefreshPresentation(record);
|
||||||
_visibleWorldEntitiesByGuid[serverGuid] = entity;
|
if (_rebucketingGuid != serverGuid && wasVisible != visible)
|
||||||
else
|
|
||||||
_visibleWorldEntitiesByGuid.Remove(serverGuid);
|
|
||||||
if (_rebucketingGuid != serverGuid)
|
|
||||||
ProjectionVisibilityChanged?.Invoke(record, visible);
|
ProjectionVisibilityChanged?.Invoke(record, visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RefreshPresentation(LiveEntityRecord record)
|
||||||
|
{
|
||||||
|
if (record.WorldEntity is not { } entity)
|
||||||
|
return;
|
||||||
|
|
||||||
|
PhysicsStateFlags state = record.FinalPhysicsState;
|
||||||
|
entity.IsDrawVisible =
|
||||||
|
(state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
|
||||||
|
|
||||||
|
bool interactionVisible = record.IsSpatiallyVisible
|
||||||
|
&& record.ProjectionKind is LiveEntityProjectionKind.World
|
||||||
|
&& (state & PhysicsStateFlags.Hidden) == 0;
|
||||||
|
if (interactionVisible)
|
||||||
|
_visibleWorldEntitiesByGuid[record.ServerGuid] = entity;
|
||||||
|
else
|
||||||
|
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
|
||||||
|
}
|
||||||
|
|
||||||
private void TearDownRecord(LiveEntityRecord record)
|
private void TearDownRecord(LiveEntityRecord record)
|
||||||
{
|
{
|
||||||
List<Exception>? failures = null;
|
List<Exception>? failures = null;
|
||||||
|
|
@ -873,6 +1029,7 @@ public sealed class LiveEntityRuntime
|
||||||
_remoteMotionByGuid.Remove(record.ServerGuid);
|
_remoteMotionByGuid.Remove(record.ServerGuid);
|
||||||
record.AnimationRuntime = null;
|
record.AnimationRuntime = null;
|
||||||
record.RemoteMotionRuntime = null;
|
record.RemoteMotionRuntime = null;
|
||||||
|
record.RequiresRemotePlacementRuntime = false;
|
||||||
record.PhysicsBody = null;
|
record.PhysicsBody = null;
|
||||||
record.ProjectileRuntime = null;
|
record.ProjectileRuntime = null;
|
||||||
record.EffectProfile = null;
|
record.EffectProfile = null;
|
||||||
|
|
|
||||||
33
src/AcDream.App/World/LiveEntityTeardown.cs
Normal file
33
src/AcDream.App/World/LiveEntityTeardown.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the independent owners that participate in one live incarnation's
|
||||||
|
/// teardown without letting an earlier callback strand later state. Retail's
|
||||||
|
/// object destruction drains these owners as separate lifecycle steps; the App
|
||||||
|
/// composition callback must preserve that symmetry even when a plugin,
|
||||||
|
/// renderer, or effect sink throws.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LiveEntityTeardown
|
||||||
|
{
|
||||||
|
internal static void Run(IEnumerable<Action> cleanups)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(cleanups);
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
foreach (Action cleanup in cleanups)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures is not null)
|
||||||
|
throw new AggregateException(
|
||||||
|
"One or more live-entity component teardown steps failed.",
|
||||||
|
failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -297,4 +297,22 @@ public sealed class TargetManager
|
||||||
foreach (var voyeur in _voyeurTable.Values.ToList())
|
foreach (var voyeur in _voyeurTable.Values.ToList())
|
||||||
SendVoyeurUpdate(voyeur, _host.Position, status);
|
SendVoyeurUpdate(voyeur, _host.Position, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcasts a terminal availability event and drops the watched-role
|
||||||
|
/// subscriptions after delivery. Retail normally removes the object from
|
||||||
|
/// cell lookup before its watchers process the event; clearing the local
|
||||||
|
/// table explicitly preserves that lifecycle boundary when the App uses
|
||||||
|
/// the target fan-out as the temporary bridge for an unavailable object.
|
||||||
|
/// The object's own watcher role (<see cref="TargetInfo"/>) is untouched.
|
||||||
|
/// </summary>
|
||||||
|
public void NotifyVoyeurOfEventAndClear(TargetStatus status)
|
||||||
|
{
|
||||||
|
if (_voyeurTable == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var voyeur in _voyeurTable.Values.ToList())
|
||||||
|
SendVoyeurUpdate(voyeur, _host.Position, status);
|
||||||
|
_voyeurTable.Clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1388,7 +1388,11 @@ public sealed class PhysicsEngine
|
||||||
bool ok = transition.FindPlacementPos(this);
|
bool ok = transition.FindPlacementPos(this);
|
||||||
var sp = transition.SpherePath;
|
var sp = transition.SpherePath;
|
||||||
var ci = transition.CollisionInfo;
|
var ci = transition.CollisionInfo;
|
||||||
bool onGround = ci.ContactPlaneValid
|
bool inContact = ci.ContactPlaneValid;
|
||||||
|
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
|
||||||
|
inContact,
|
||||||
|
ci.ContactPlane.Normal);
|
||||||
|
bool onGround = inContact
|
||||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||||||
|
|
||||||
return new ResolveResult(
|
return new ResolveResult(
|
||||||
|
|
@ -1397,6 +1401,12 @@ public sealed class PhysicsEngine
|
||||||
onGround,
|
onGround,
|
||||||
ci.CollisionNormalValid,
|
ci.CollisionNormalValid,
|
||||||
ci.CollisionNormal,
|
ci.CollisionNormal,
|
||||||
ok);
|
ok,
|
||||||
|
Orientation: sp.CurOrientation,
|
||||||
|
InContact: inContact,
|
||||||
|
OnWalkable: onWalkable,
|
||||||
|
ContactPlane: ci.ContactPlane,
|
||||||
|
ContactPlaneCellId: ci.ContactPlaneCellId,
|
||||||
|
ContactPlaneIsWater: ci.ContactPlaneIsWater);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,70 @@ public static class PhysicsObjUpdate
|
||||||
body.calc_acceleration();
|
body.calc_acceleration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Commits the contact/walkable/collision-response tail of retail
|
||||||
|
/// <c>CPhysicsObj::SetPositionInternal</c> (<c>0x00515330</c>) in its
|
||||||
|
/// original order. The pre-transition flags are explicit because a
|
||||||
|
/// deferred placement may temporarily park the body without contact while
|
||||||
|
/// still needing the source edge when its destination cell becomes ready.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Retail writes Contact and recalculates acceleration, calls
|
||||||
|
/// <c>set_on_walkable</c> (which invokes HitGround/LeaveGround), then calls
|
||||||
|
/// <c>handle_all_collisions</c> at <c>0x005154FE</c>. Collision response
|
||||||
|
/// must therefore observe any velocity change made by the movement
|
||||||
|
/// callback; moving that callback after reflection changes the result.
|
||||||
|
/// </remarks>
|
||||||
|
public static void CommitSetPositionTransition(
|
||||||
|
PhysicsBody body,
|
||||||
|
bool inContact,
|
||||||
|
bool onWalkable,
|
||||||
|
bool collisionNormalValid,
|
||||||
|
Vector3 collisionNormal,
|
||||||
|
bool previousContact,
|
||||||
|
bool previousOnWalkable,
|
||||||
|
Action? hitGround = null,
|
||||||
|
Action? leaveGround = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(body);
|
||||||
|
|
||||||
|
// SetPositionInternal replaces Contact first but retains the source
|
||||||
|
// OnWalkable bit through its first calc_acceleration call. A deferred
|
||||||
|
// teleport may have parked the live body with both bits cleared, so
|
||||||
|
// restore the captured source bit explicitly before reproducing that
|
||||||
|
// ordering.
|
||||||
|
if (previousOnWalkable)
|
||||||
|
body.TransientState |= TransientStateFlags.OnWalkable;
|
||||||
|
else
|
||||||
|
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
||||||
|
|
||||||
|
if (inContact)
|
||||||
|
body.TransientState |= TransientStateFlags.Contact;
|
||||||
|
else
|
||||||
|
body.TransientState &= ~TransientStateFlags.Contact;
|
||||||
|
body.calc_acceleration();
|
||||||
|
|
||||||
|
bool finalOnWalkable = inContact && onWalkable;
|
||||||
|
if (finalOnWalkable)
|
||||||
|
body.TransientState |= TransientStateFlags.OnWalkable;
|
||||||
|
else
|
||||||
|
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
||||||
|
|
||||||
|
if (!previousOnWalkable && finalOnWalkable)
|
||||||
|
hitGround?.Invoke();
|
||||||
|
else if (previousOnWalkable && !finalOnWalkable)
|
||||||
|
leaveGround?.Invoke();
|
||||||
|
body.calc_acceleration();
|
||||||
|
|
||||||
|
HandleAllCollisions(
|
||||||
|
body,
|
||||||
|
collisionNormalValid,
|
||||||
|
collisionNormal,
|
||||||
|
previousContact,
|
||||||
|
previousOnWalkable,
|
||||||
|
finalOnWalkable);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
|
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
|
||||||
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on
|
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,10 @@ public readonly record struct ResolveResult(
|
||||||
/// Exact retail walkability result after testing the contact-plane normal
|
/// Exact retail walkability result after testing the contact-plane normal
|
||||||
/// against <see cref="PhysicsGlobals.FloorZ"/>.
|
/// against <see cref="PhysicsGlobals.FloorZ"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool OnWalkable = false);
|
bool OnWalkable = false,
|
||||||
|
/// <summary>The accepted SetPosition contact plane, when present.</summary>
|
||||||
|
Plane ContactPlane = default,
|
||||||
|
/// <summary>Full cell that owns <see cref="ContactPlane"/>.</summary>
|
||||||
|
uint ContactPlaneCellId = 0,
|
||||||
|
/// <summary>Whether the accepted contact plane is water.</summary>
|
||||||
|
bool ContactPlaneIsWater = false);
|
||||||
|
|
|
||||||
89
src/AcDream.Core/Physics/RetailPhysicsStateTransition.cs
Normal file
89
src/AcDream.Core/Physics/RetailPhysicsStateTransition.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of retail <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>) and
|
||||||
|
/// the nested <c>set_hidden</c> call (<c>0x00514C60</c>).
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct RetailPhysicsStateTransition(
|
||||||
|
PhysicsStateFlags PreviousState,
|
||||||
|
PhysicsStateFlags RequestedState,
|
||||||
|
PhysicsStateFlags FinalState,
|
||||||
|
bool LightingChanged,
|
||||||
|
bool NoDrawChanged,
|
||||||
|
RetailHiddenTransition HiddenTransition)
|
||||||
|
{
|
||||||
|
public bool HasChanges => PreviousState != FinalState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RetailHiddenTransition
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
BecameHidden,
|
||||||
|
BecameVisible,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure port of retail's PhysicsState side-effect ordering. The server's raw
|
||||||
|
/// state is installed first; Lighting and NoDraw are processed next; Hidden is
|
||||||
|
/// processed last and may rewrite ReportCollisions/IgnoreCollisions.
|
||||||
|
/// </summary>
|
||||||
|
public static class RetailPhysicsStateTransitions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>CPhysicsObj</c> constructor state at <c>0x00512508</c>.
|
||||||
|
/// This deliberately does not contain Hidden: a normal visible
|
||||||
|
/// CreateObject must not manufacture an UnHide script.
|
||||||
|
/// </summary>
|
||||||
|
public const PhysicsStateFlags ConstructorState =
|
||||||
|
PhysicsStateFlags.EdgeSlide
|
||||||
|
| PhysicsStateFlags.Lighting
|
||||||
|
| PhysicsStateFlags.Gravity
|
||||||
|
| PhysicsStateFlags.ReportCollisions;
|
||||||
|
|
||||||
|
public static RetailPhysicsStateTransition Apply(
|
||||||
|
PhysicsStateFlags previousState,
|
||||||
|
PhysicsStateFlags requestedState)
|
||||||
|
{
|
||||||
|
// CPhysicsObj::set_state compares only the low 16 bits. Every state
|
||||||
|
// with a set_state side effect (Lighting, NoDraw, Hidden) lives there.
|
||||||
|
uint changedLow = ((uint)previousState ^ (uint)requestedState) & 0xFFFFu;
|
||||||
|
bool lightingChanged =
|
||||||
|
(changedLow & (uint)PhysicsStateFlags.Lighting) != 0;
|
||||||
|
bool noDrawChanged =
|
||||||
|
(changedLow & (uint)PhysicsStateFlags.NoDraw) != 0;
|
||||||
|
bool hiddenChanged =
|
||||||
|
(changedLow & (uint)PhysicsStateFlags.Hidden) != 0;
|
||||||
|
|
||||||
|
PhysicsStateFlags finalState = requestedState;
|
||||||
|
RetailHiddenTransition hiddenTransition = RetailHiddenTransition.None;
|
||||||
|
if (hiddenChanged)
|
||||||
|
{
|
||||||
|
if ((requestedState & PhysicsStateFlags.Hidden) != 0)
|
||||||
|
{
|
||||||
|
// set_hidden(true): report_collision_end, clear reporting,
|
||||||
|
// then enable collision-ignore before hiding from the cell.
|
||||||
|
finalState &= ~PhysicsStateFlags.ReportCollisions;
|
||||||
|
finalState |= PhysicsStateFlags.Hidden
|
||||||
|
| PhysicsStateFlags.IgnoreCollisions;
|
||||||
|
hiddenTransition = RetailHiddenTransition.BecameHidden;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// set_hidden(false): clear ignore and restore collision
|
||||||
|
// reporting even when the incoming raw state omitted it.
|
||||||
|
finalState &= ~(PhysicsStateFlags.Hidden
|
||||||
|
| PhysicsStateFlags.IgnoreCollisions);
|
||||||
|
finalState |= PhysicsStateFlags.ReportCollisions;
|
||||||
|
hiddenTransition = RetailHiddenTransition.BecameVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RetailPhysicsStateTransition(
|
||||||
|
previousState,
|
||||||
|
requestedState,
|
||||||
|
finalState,
|
||||||
|
lightingChanged,
|
||||||
|
noDrawChanged,
|
||||||
|
hiddenTransition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,22 @@ public sealed class WorldEntity
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
|
public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the root PartArray participates in mesh drawing. Live-object
|
||||||
|
/// PhysicsState NoDraw/Hidden transitions change this without destroying
|
||||||
|
/// the entity or its scripts, particles, lights, cell identity, or cached
|
||||||
|
/// mesh resources. Dat/static entities remain visible by default.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDrawVisible { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retained-tree visibility inherited from attached ancestors. Retail
|
||||||
|
/// draws child physics objects through their parent hierarchy; acdream
|
||||||
|
/// flattens them into independent draw entries, so this separate bit keeps
|
||||||
|
/// ancestor suppression out of the child's own PhysicsState.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAncestorDrawVisible { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stable Setup-part-indexed poses used by attachment and effect hooks.
|
/// Stable Setup-part-indexed poses used by attachment and effect hooks.
|
||||||
/// Unlike <see cref="MeshRefs"/>, this array never compacts around a DAT
|
/// Unlike <see cref="MeshRefs"/>, this array never compacts around a DAT
|
||||||
|
|
|
||||||
82
tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs
Normal file
82
tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class PlayerMovementHiddenTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TickHidden_PositionManagerMovesRootAndEffectPoseWithoutPhysicsInput()
|
||||||
|
{
|
||||||
|
const uint playerGuid = 0x50000001u;
|
||||||
|
const uint targetGuid = 0x50000002u;
|
||||||
|
const uint cellId = 0x01010001u;
|
||||||
|
|
||||||
|
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||||
|
controller.SetPosition(Vector3.Zero, cellId, Vector3.Zero);
|
||||||
|
|
||||||
|
var hosts = new Dictionary<uint, IPhysicsObjHost>();
|
||||||
|
EntityPhysicsHost? playerHost = null;
|
||||||
|
playerHost = new EntityPhysicsHost(
|
||||||
|
playerGuid,
|
||||||
|
() => controller.CellPosition,
|
||||||
|
() => controller.BodyVelocity,
|
||||||
|
() => 0.48f,
|
||||||
|
() => controller.BodyInContact,
|
||||||
|
() => 3f,
|
||||||
|
() => 0.1,
|
||||||
|
() => 0.1,
|
||||||
|
id => hosts.GetValueOrDefault(id),
|
||||||
|
info => playerHost!.PositionManager.HandleUpdateTarget(info),
|
||||||
|
() => { });
|
||||||
|
var targetPosition = new Position(
|
||||||
|
cellId,
|
||||||
|
new Vector3(5f, 0f, 0f),
|
||||||
|
Quaternion.Identity);
|
||||||
|
var targetHost = new EntityPhysicsHost(
|
||||||
|
targetGuid,
|
||||||
|
() => targetPosition,
|
||||||
|
() => Vector3.Zero,
|
||||||
|
() => 0.48f,
|
||||||
|
() => true,
|
||||||
|
() => null,
|
||||||
|
() => 0.1,
|
||||||
|
() => 0.1,
|
||||||
|
id => hosts.GetValueOrDefault(id),
|
||||||
|
_ => { },
|
||||||
|
() => { });
|
||||||
|
hosts.Add(playerGuid, playerHost);
|
||||||
|
hosts.Add(targetGuid, targetHost);
|
||||||
|
controller.PositionManager = playerHost.PositionManager;
|
||||||
|
playerHost.PositionManager.StickTo(targetGuid, radius: 0.48f, height: 1.835f);
|
||||||
|
|
||||||
|
var entity = new WorldEntity
|
||||||
|
{
|
||||||
|
Id = 7u,
|
||||||
|
ServerGuid = playerGuid,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
ParentCellId = cellId,
|
||||||
|
MeshRefs = Array.Empty<MeshRef>(),
|
||||||
|
};
|
||||||
|
var poses = new EntityEffectPoseRegistry();
|
||||||
|
poses.Publish(entity, Array.Empty<Matrix4x4>());
|
||||||
|
|
||||||
|
MovementResult result = controller.TickHidden(0.1f);
|
||||||
|
entity.SetPosition(result.Position);
|
||||||
|
entity.ParentCellId = result.CellId;
|
||||||
|
Assert.True(poses.UpdateRoot(entity));
|
||||||
|
|
||||||
|
Assert.InRange(result.Position.X, 0.01f, 1.5f);
|
||||||
|
Assert.Equal(0f, result.Position.Z);
|
||||||
|
Assert.Equal(Vector3.Zero, controller.BodyVelocity);
|
||||||
|
Assert.True(poses.TryGetRootPose(entity.Id, out Matrix4x4 root));
|
||||||
|
Assert.Equal(result.Position, root.Translation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,96 @@ public sealed class ProjectileControllerTests
|
||||||
Assert.Same(record.ProjectileRuntime!.Body, record.PhysicsBody);
|
Assert.Same(record.ProjectileRuntime!.Body, record.PhysicsBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hidden_PausesProjectileWithoutClearingActiveOrReplayingClockBacklog()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
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);
|
||||||
|
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
|
||||||
|
PhysicsBody body = record.PhysicsBody!;
|
||||||
|
Assert.True(body.IsActive);
|
||||||
|
|
||||||
|
record.FinalPhysicsState = MissileState
|
||||||
|
| PhysicsStateFlags.Hidden
|
||||||
|
| PhysicsStateFlags.IgnoreCollisions;
|
||||||
|
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||||
|
Guid, 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, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||||
|
|
||||||
|
record.FinalPhysicsState = MissileState;
|
||||||
|
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||||
|
Guid, record.FinalPhysicsState, 5.0, 1, 1));
|
||||||
|
fixture.Controller.Tick(5.1, 1, 1, playerWorldPosition: null);
|
||||||
|
|
||||||
|
Assert.True(entity.Position.X > hiddenPosition.X);
|
||||||
|
Assert.True(entity.Position.X < hiddenPosition.X + 2f);
|
||||||
|
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HiddenSharedRemoteProjectile_UsesPositionManagerNarrowTickExactlyOnce()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||||
|
WorldEntity entity = record.WorldEntity!;
|
||||||
|
Assert.Null(record.AnimationRuntime);
|
||||||
|
var remote = new GameWindow.RemoteMotion();
|
||||||
|
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
|
||||||
|
remote.Body.Position = entity.Position;
|
||||||
|
remote.Body.Orientation = entity.Rotation;
|
||||||
|
fixture.Live.SetRemoteMotionRuntime(Guid, remote);
|
||||||
|
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
|
||||||
|
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
|
||||||
|
|
||||||
|
record.FinalPhysicsState = MissileState
|
||||||
|
| PhysicsStateFlags.Hidden
|
||||||
|
| PhysicsStateFlags.IgnoreCollisions;
|
||||||
|
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||||
|
Guid, record.FinalPhysicsState, 1.1, 1, 1));
|
||||||
|
remote.Interp.Enqueue(
|
||||||
|
entity.Position + Vector3.UnitX,
|
||||||
|
heading: 0f,
|
||||||
|
isMovingTo: false,
|
||||||
|
currentBodyPosition: remote.Body.Position);
|
||||||
|
var updater = new RemotePhysicsUpdater(
|
||||||
|
fixture.Engine,
|
||||||
|
(_, _) => (0.48f, 1.835f),
|
||||||
|
(_, _, _, _) => { });
|
||||||
|
|
||||||
|
int publishedRoots = 0;
|
||||||
|
updater.TickHiddenEntities(
|
||||||
|
fixture.Live,
|
||||||
|
localPlayerServerGuid: 0x50000001u,
|
||||||
|
dt: 0.1f,
|
||||||
|
_ => publishedRoots++);
|
||||||
|
Vector3 afterNarrowTick = entity.Position;
|
||||||
|
fixture.Controller.Tick(1.2, 1, 1, playerWorldPosition: null);
|
||||||
|
|
||||||
|
Assert.True(afterNarrowTick.X > 10f);
|
||||||
|
Assert.Equal(afterNarrowTick, entity.Position);
|
||||||
|
Assert.Equal(1, publishedRoots);
|
||||||
|
Assert.True(fixture.Controller.HandlesMovement(Guid));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PendingDestinationAndLaterHydration_RetainIdentityAndProjectileOwner()
|
public void PendingDestinationAndLaterHydration_RetainIdentityAndProjectileOwner()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
166
tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
Normal file
166
tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class RemotePhysicsUpdaterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics()
|
||||||
|
{
|
||||||
|
var motion = new GameWindow.RemoteMotion();
|
||||||
|
motion.Body.Position = Vector3.Zero;
|
||||||
|
motion.Body.Orientation = Quaternion.Identity;
|
||||||
|
motion.Body.Velocity = new Vector3(4f, 0f, 5f);
|
||||||
|
motion.Body.Acceleration = new Vector3(0f, 0f, -9.8f);
|
||||||
|
motion.CellId = 0x01010001u;
|
||||||
|
motion.Interp.Enqueue(
|
||||||
|
new Vector3(1f, 0f, 0f),
|
||||||
|
heading: 0f,
|
||||||
|
isMovingTo: false,
|
||||||
|
currentBodyPosition: Vector3.Zero);
|
||||||
|
var entity = new WorldEntity
|
||||||
|
{
|
||||||
|
Id = 1u,
|
||||||
|
ServerGuid = 0x70000001u,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = Array.Empty<MeshRef>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var updater = new RemotePhysicsUpdater(
|
||||||
|
new PhysicsEngine(),
|
||||||
|
(_, _) => (0.48f, 1.835f),
|
||||||
|
(_, _, _, _) => { });
|
||||||
|
var poses = new EntityEffectPoseRegistry();
|
||||||
|
poses.Publish(entity, Array.Empty<Matrix4x4>());
|
||||||
|
updater.TickHidden(motion, entity, 0.1f);
|
||||||
|
Assert.True(poses.UpdateRoot(entity));
|
||||||
|
|
||||||
|
Assert.InRange(motion.Body.Position.X, 0.01f, 1f);
|
||||||
|
Assert.Equal(0f, motion.Body.Position.Z);
|
||||||
|
Assert.Equal(new Vector3(4f, 0f, 5f), motion.Body.Velocity);
|
||||||
|
Assert.Equal(motion.Body.Position, entity.Position);
|
||||||
|
Assert.Equal(motion.CellId, entity.ParentCellId);
|
||||||
|
Assert.True(poses.TryGetRootPose(entity.Id, out Matrix4x4 root));
|
||||||
|
Assert.Equal(entity.Position, root.Translation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide()
|
||||||
|
{
|
||||||
|
var engine = BuildBoundaryEngine();
|
||||||
|
var updater = new RemotePhysicsUpdater(
|
||||||
|
engine,
|
||||||
|
(_, _) => (0.48f, 1.835f),
|
||||||
|
(_, _, _, _) => { });
|
||||||
|
var motion = new GameWindow.RemoteMotion();
|
||||||
|
motion.Body.Position = new Vector3(191f, 10f, 50f);
|
||||||
|
motion.Body.Orientation = Quaternion.Identity;
|
||||||
|
motion.Body.TransientState = TransientStateFlags.Contact
|
||||||
|
| TransientStateFlags.OnWalkable;
|
||||||
|
uint canonicalCell = 0xA9B40039u;
|
||||||
|
int canonicalWrites = 0;
|
||||||
|
motion.BindCanonicalCell(
|
||||||
|
() => canonicalCell,
|
||||||
|
value =>
|
||||||
|
{
|
||||||
|
canonicalCell = value;
|
||||||
|
canonicalWrites++;
|
||||||
|
});
|
||||||
|
motion.CellId = canonicalCell;
|
||||||
|
canonicalWrites = 0;
|
||||||
|
motion.Interp.Enqueue(
|
||||||
|
new Vector3(193f, 10f, 50f),
|
||||||
|
heading: 0f,
|
||||||
|
isMovingTo: false,
|
||||||
|
currentBodyPosition: motion.Body.Position);
|
||||||
|
var entity = new WorldEntity
|
||||||
|
{
|
||||||
|
Id = 1u,
|
||||||
|
ServerGuid = 0x70000002u,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = motion.Body.Position,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = Array.Empty<MeshRef>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
updater.TickHidden(motion, entity, 2f);
|
||||||
|
|
||||||
|
Assert.Equal(0xAAB40001u, canonicalCell);
|
||||||
|
Assert.Equal(canonicalCell, entity.ParentCellId);
|
||||||
|
Assert.True(canonicalWrites > 0);
|
||||||
|
Assert.True(entity.Position.X > 192f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TickHidden_LandingSynchronizesRemoteAirborneState()
|
||||||
|
{
|
||||||
|
var engine = BuildBoundaryEngine();
|
||||||
|
var updater = new RemotePhysicsUpdater(
|
||||||
|
engine,
|
||||||
|
(_, _) => (0.48f, 1.835f),
|
||||||
|
(_, _, _, _) => { });
|
||||||
|
var motion = new GameWindow.RemoteMotion
|
||||||
|
{
|
||||||
|
Airborne = true,
|
||||||
|
};
|
||||||
|
motion.Body.Position = new Vector3(10f, 10f, 50f);
|
||||||
|
motion.Body.Orientation = Quaternion.Identity;
|
||||||
|
motion.Body.TransientState = TransientStateFlags.Active;
|
||||||
|
motion.CellId = 0xA9B40001u;
|
||||||
|
motion.Interp.Enqueue(
|
||||||
|
new Vector3(11f, 10f, 50f),
|
||||||
|
heading: 0f,
|
||||||
|
isMovingTo: false,
|
||||||
|
currentBodyPosition: motion.Body.Position);
|
||||||
|
var entity = new WorldEntity
|
||||||
|
{
|
||||||
|
Id = 3u,
|
||||||
|
ServerGuid = 0x70000003u,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = motion.Body.Position,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = Array.Empty<MeshRef>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
updater.TickHidden(motion, entity, 0.1f);
|
||||||
|
|
||||||
|
Assert.True(motion.Body.InContact);
|
||||||
|
Assert.True(motion.Body.OnWalkable);
|
||||||
|
Assert.False(motion.Airborne);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1288
tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs
Normal file
1288
tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs
Normal file
File diff suppressed because it is too large
Load diff
38
tests/AcDream.App.Tests/Physics/RemoteTeleportHookTests.cs
Normal file
38
tests/AcDream.App.Tests/Physics/RemoteTeleportHookTests.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class RemoteTeleportHookTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Execute_UsesRetailOrderAndExactCancelContext()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
WeenieError cancelContext = WeenieError.None;
|
||||||
|
|
||||||
|
RemoteTeleportHook.Execute(new RemoteTeleportHookActions(
|
||||||
|
CancelMoveTo: error =>
|
||||||
|
{
|
||||||
|
cancelContext = error;
|
||||||
|
calls.Add("cancel");
|
||||||
|
},
|
||||||
|
UnStick: () => calls.Add("unstick"),
|
||||||
|
StopInterpolating: () => calls.Add("stop-interpolating"),
|
||||||
|
UnConstrain: () => calls.Add("unconstrain"),
|
||||||
|
NotifyTeleported: () => calls.Add("notify-teleported"),
|
||||||
|
ReportCollisionEnd: () => calls.Add("collision-end")));
|
||||||
|
|
||||||
|
Assert.Equal(0x3Cu, (uint)cancelContext);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"cancel",
|
||||||
|
"unstick",
|
||||||
|
"stop-interpolating",
|
||||||
|
"unconstrain",
|
||||||
|
"notify-teleported",
|
||||||
|
"collision-end",
|
||||||
|
],
|
||||||
|
calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs
Normal file
194
tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class RemoteTeleportPlacementTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Apply_HardSnapsFullFrameAndPhysicsClock()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
Orientation = Quaternion.Identity,
|
||||||
|
LastUpdateTime = 2.0,
|
||||||
|
};
|
||||||
|
body.SnapToCell(
|
||||||
|
0x01010001u,
|
||||||
|
new Vector3(3f, 4f, 5f),
|
||||||
|
new Vector3(3f, 4f, 5f));
|
||||||
|
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
|
||||||
|
Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
|
||||||
|
|
||||||
|
RemoteTeleportPlacement.Apply(
|
||||||
|
remote,
|
||||||
|
body,
|
||||||
|
new ResolveResult(
|
||||||
|
new Vector3(210f, 220f, 12f),
|
||||||
|
0x02020123u,
|
||||||
|
IsOnGround: false),
|
||||||
|
new Vector3(18f, 28f, 12f),
|
||||||
|
orientation,
|
||||||
|
17.5,
|
||||||
|
previousContact: body.InContact,
|
||||||
|
previousOnWalkable: body.OnWalkable);
|
||||||
|
|
||||||
|
Assert.Equal(new Vector3(210f, 220f, 12f), body.Position);
|
||||||
|
Assert.Equal(0x02020123u, body.CellPosition.ObjCellId);
|
||||||
|
Assert.Equal(new Vector3(18f, 28f, 12f), body.CellPosition.Frame.Origin);
|
||||||
|
Assert.Equal(orientation, body.Orientation);
|
||||||
|
Assert.Equal(17.5, body.LastUpdateTime);
|
||||||
|
Assert.True(body.InWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_InstallsPlacementDerivedContactAndSynchronizesAirborneState()
|
||||||
|
{
|
||||||
|
var velocity = new Vector3(1f, 2f, 3f);
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
Velocity = velocity,
|
||||||
|
TransientState = TransientStateFlags.Active,
|
||||||
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||||
|
};
|
||||||
|
var contactPlane = new Plane(Vector3.UnitZ, 0f);
|
||||||
|
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body)
|
||||||
|
{
|
||||||
|
Airborne = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
RemoteTeleportPlacement.Apply(
|
||||||
|
remote,
|
||||||
|
body,
|
||||||
|
new ResolveResult(
|
||||||
|
new Vector3(200f, 200f, 50f),
|
||||||
|
0x02020001u,
|
||||||
|
IsOnGround: true,
|
||||||
|
InContact: true,
|
||||||
|
OnWalkable: true,
|
||||||
|
ContactPlane: contactPlane,
|
||||||
|
ContactPlaneCellId: 0x02020001u),
|
||||||
|
new Vector3(8f, 8f, 50f),
|
||||||
|
Quaternion.Identity,
|
||||||
|
9.0,
|
||||||
|
previousContact: false,
|
||||||
|
previousOnWalkable: false);
|
||||||
|
|
||||||
|
Assert.Equal(Vector3.Zero, body.Velocity);
|
||||||
|
Assert.True(body.InContact);
|
||||||
|
Assert.True(body.OnWalkable);
|
||||||
|
Assert.True(body.ContactPlaneValid);
|
||||||
|
Assert.Equal(contactPlane, body.ContactPlane);
|
||||||
|
Assert.False(remote.Airborne);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_PendingHydrationUsesPreservedSourceFlagsBeforeCollisionResponse()
|
||||||
|
{
|
||||||
|
var originalVelocity = new Vector3(-3f, 0f, 0f);
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
Velocity = originalVelocity,
|
||||||
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||||
|
};
|
||||||
|
// ParkPending deliberately clears these flags while the destination
|
||||||
|
// landblock is absent. SetPositionInternal must still receive the
|
||||||
|
// grounded source edge captured before parking.
|
||||||
|
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||||||
|
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body)
|
||||||
|
{
|
||||||
|
Airborne = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
RemoteTeleportPlacement.Apply(
|
||||||
|
remote,
|
||||||
|
body,
|
||||||
|
new ResolveResult(
|
||||||
|
new Vector3(200f, 200f, 50f),
|
||||||
|
0x02020001u,
|
||||||
|
IsOnGround: true,
|
||||||
|
CollisionNormalValid: true,
|
||||||
|
CollisionNormal: Vector3.UnitX,
|
||||||
|
InContact: true,
|
||||||
|
OnWalkable: true,
|
||||||
|
ContactPlane: new Plane(Vector3.UnitZ, 0f),
|
||||||
|
ContactPlaneCellId: 0x02020001u),
|
||||||
|
new Vector3(8f, 8f, 50f),
|
||||||
|
Quaternion.Identity,
|
||||||
|
9.0,
|
||||||
|
previousContact: true,
|
||||||
|
previousOnWalkable: true);
|
||||||
|
|
||||||
|
// Staying on walkable ground suppresses reflection. If the parked
|
||||||
|
// false/false flags were used, the -X velocity would reflect to +X.
|
||||||
|
Assert.Equal(originalVelocity, body.Velocity);
|
||||||
|
Assert.True(body.OnWalkable);
|
||||||
|
Assert.False(remote.Airborne);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_PendingGroundToSteepContact_RestoresSourceWalkabilityForFirstAcceleration()
|
||||||
|
{
|
||||||
|
var body = new PhysicsBody
|
||||||
|
{
|
||||||
|
Omega = new Vector3(0f, 0f, 3f),
|
||||||
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||||||
|
};
|
||||||
|
// The unloaded-destination parking frame cleared both bits. Retail
|
||||||
|
// restores the captured source OnWalkable bit through the first
|
||||||
|
// calc_acceleration, which clears grounded angular drift, before
|
||||||
|
// set_on_walkable installs the steep destination's false value.
|
||||||
|
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||||||
|
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
|
||||||
|
|
||||||
|
RemoteTeleportPlacement.Apply(
|
||||||
|
remote,
|
||||||
|
body,
|
||||||
|
new ResolveResult(
|
||||||
|
new Vector3(200f, 200f, 50f),
|
||||||
|
0x02020001u,
|
||||||
|
IsOnGround: false,
|
||||||
|
InContact: true,
|
||||||
|
OnWalkable: false,
|
||||||
|
ContactPlane: new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.2f)), 0f),
|
||||||
|
ContactPlaneCellId: 0x02020001u),
|
||||||
|
new Vector3(8f, 8f, 50f),
|
||||||
|
Quaternion.Identity,
|
||||||
|
9.0,
|
||||||
|
previousContact: true,
|
||||||
|
previousOnWalkable: true);
|
||||||
|
|
||||||
|
Assert.Equal(Vector3.Zero, body.Omega);
|
||||||
|
Assert.True(body.InContact);
|
||||||
|
Assert.False(body.OnWalkable);
|
||||||
|
Assert.True(remote.Airborne);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_RejectsMalformedPlacementWithoutMutatingBody()
|
||||||
|
{
|
||||||
|
var original = new Vector3(7f, 8f, 9f);
|
||||||
|
var body = new PhysicsBody { Position = original };
|
||||||
|
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
RemoteTeleportPlacement.Apply(
|
||||||
|
remote,
|
||||||
|
body,
|
||||||
|
new ResolveResult(
|
||||||
|
new Vector3(float.NaN, 0f, 0f),
|
||||||
|
0,
|
||||||
|
IsOnGround: false,
|
||||||
|
Ok: false),
|
||||||
|
Vector3.Zero,
|
||||||
|
Quaternion.Identity,
|
||||||
|
double.NaN,
|
||||||
|
previousContact: false,
|
||||||
|
previousOnWalkable: false));
|
||||||
|
|
||||||
|
Assert.Equal(original, body.Position);
|
||||||
|
Assert.Equal(0u, body.CellPosition.ObjCellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,38 @@ public sealed class AttachmentUpdateOrderTests
|
||||||
Assert.Equal(new Vector3(100f, 2f, 0f), renderedOrigin, new Vector3ToleranceComparer());
|
Assert.Equal(new Vector3(100f, 2f, 0f), renderedOrigin, new Vector3ToleranceComparer());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NestedDrawable_InheritsAncestorSuppressionWithoutMutatingOwnState()
|
||||||
|
{
|
||||||
|
var root = Entity(1u);
|
||||||
|
var child = Entity(2u);
|
||||||
|
var grandchild = Entity(3u);
|
||||||
|
root.IsDrawVisible = false;
|
||||||
|
|
||||||
|
EquippedChildRenderController.ApplyParentDrawVisibility(child, root);
|
||||||
|
EquippedChildRenderController.ApplyParentDrawVisibility(grandchild, child);
|
||||||
|
|
||||||
|
Assert.True(child.IsDrawVisible);
|
||||||
|
Assert.False(child.IsAncestorDrawVisible);
|
||||||
|
Assert.True(grandchild.IsDrawVisible);
|
||||||
|
Assert.False(grandchild.IsAncestorDrawVisible);
|
||||||
|
|
||||||
|
root.IsDrawVisible = true;
|
||||||
|
EquippedChildRenderController.ApplyParentDrawVisibility(child, root);
|
||||||
|
EquippedChildRenderController.ApplyParentDrawVisibility(grandchild, child);
|
||||||
|
Assert.True(child.IsAncestorDrawVisible);
|
||||||
|
Assert.True(grandchild.IsAncestorDrawVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AcDream.Core.World.WorldEntity Entity(uint id) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AncestorTeardownCollectsEntireAttachmentSubtreePostOrder()
|
public void AncestorTeardownCollectsEntireAttachmentSubtreePostOrder()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
65
tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs
Normal file
65
tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class EntityPhysicsHostTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NotifyHidden_FansNonOkStatusAndClearsWatcherSubscription()
|
||||||
|
{
|
||||||
|
const uint watcherId = 0x50000001u;
|
||||||
|
const uint targetId = 0x70000001u;
|
||||||
|
var hosts = new Dictionary<uint, IPhysicsObjHost>();
|
||||||
|
var watcherUpdates = new List<TargetInfo>();
|
||||||
|
|
||||||
|
EntityPhysicsHost watcher = CreateHost(
|
||||||
|
watcherId,
|
||||||
|
hosts,
|
||||||
|
watcherUpdates.Add);
|
||||||
|
EntityPhysicsHost target = CreateHost(targetId, hosts, _ => { });
|
||||||
|
hosts.Add(watcherId, watcher);
|
||||||
|
hosts.Add(targetId, target);
|
||||||
|
watcher.SetTarget(0u, targetId, 1f, 0.1);
|
||||||
|
watcherUpdates.Clear();
|
||||||
|
|
||||||
|
target.NotifyHidden();
|
||||||
|
|
||||||
|
TargetInfo hidden = Assert.Single(watcherUpdates);
|
||||||
|
Assert.Equal(TargetStatus.ExitWorld, hidden.Status);
|
||||||
|
Assert.Null(watcher.TargetManager.TargetInfo);
|
||||||
|
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(watcherId));
|
||||||
|
|
||||||
|
watcherUpdates.Clear();
|
||||||
|
watcher.SetTarget(0u, targetId, 1f, 0.1);
|
||||||
|
|
||||||
|
TargetInfo restored = Assert.Single(watcherUpdates);
|
||||||
|
Assert.Equal(TargetStatus.Ok, restored.Status);
|
||||||
|
Assert.Equal(targetId, restored.ObjectId);
|
||||||
|
Assert.True(target.TargetManager.VoyeurTable.ContainsKey(watcherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EntityPhysicsHost CreateHost(
|
||||||
|
uint id,
|
||||||
|
IReadOnlyDictionary<uint, IPhysicsObjHost> hosts,
|
||||||
|
Action<TargetInfo> update)
|
||||||
|
{
|
||||||
|
return new EntityPhysicsHost(
|
||||||
|
id,
|
||||||
|
getPosition: () => new Position(
|
||||||
|
0x01010001u,
|
||||||
|
new Vector3(id == 0x50000001u ? 0f : 2f, 0f, 0f),
|
||||||
|
Quaternion.Identity),
|
||||||
|
getVelocity: () => Vector3.Zero,
|
||||||
|
getRadius: () => 0.5f,
|
||||||
|
inContact: () => true,
|
||||||
|
minterpMaxSpeed: () => 1f,
|
||||||
|
curTime: () => 1.0,
|
||||||
|
physicsTimerTime: () => 1.0,
|
||||||
|
getObjectA: objectId => hosts.GetValueOrDefault(objectId),
|
||||||
|
handleUpdateTarget: update,
|
||||||
|
interruptCurrentMovement: () => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -166,6 +166,28 @@ public sealed class EntityEffectControllerTests
|
||||||
Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid);
|
Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PreparedOwner_DoesNotReplayPendingPacketsUntilConstructionBarrierOpens()
|
||||||
|
{
|
||||||
|
var fixture = new Fixture();
|
||||||
|
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
|
||||||
|
WorldSession.EntitySpawn spawn = Spawn(Guid, 1);
|
||||||
|
fixture.Runtime.RegisterLiveEntity(spawn);
|
||||||
|
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
|
||||||
|
fixture.Runtime.MaterializeLiveEntity(
|
||||||
|
Guid,
|
||||||
|
spawn.Position!.Value.LandblockId,
|
||||||
|
id => Entity(id, Guid));
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.PrepareLiveEntityOwner(Guid));
|
||||||
|
Assert.Equal(1, fixture.Controller.PendingPacketCount);
|
||||||
|
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.ReplayPendingForLiveEntity(Guid));
|
||||||
|
Assert.Equal(0, fixture.Controller.PendingPacketCount);
|
||||||
|
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
|
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,46 @@ public sealed class RadarSnapshotProviderTests
|
||||||
Assert.True(snapshot.UiLocked);
|
Assert.True(snapshot.UiLocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildSnapshot_UsesCanonicalPlayerWhileHiddenTargetsStayExcluded()
|
||||||
|
{
|
||||||
|
const uint player = 20u;
|
||||||
|
const uint hiddenTarget = 21u;
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
|
||||||
|
objects.Ingest(Weenie(hiddenTarget, "Hidden target", ItemType.Creature) with
|
||||||
|
{
|
||||||
|
RadarBehavior = (byte)RadarBehavior.ShowAlways,
|
||||||
|
});
|
||||||
|
var canonical = new Dictionary<uint, WorldEntity>
|
||||||
|
{
|
||||||
|
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
|
||||||
|
[hiddenTarget] = Entity(hiddenTarget, new Vector3(5f, 0f, 0f), Quaternion.Identity),
|
||||||
|
};
|
||||||
|
var interactionVisible = new Dictionary<uint, WorldEntity>();
|
||||||
|
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
|
||||||
|
{
|
||||||
|
[player] = Spawn(player),
|
||||||
|
[hiddenTarget] = Spawn(hiddenTarget),
|
||||||
|
};
|
||||||
|
var provider = new RadarSnapshotProvider(
|
||||||
|
objects,
|
||||||
|
interactionVisible,
|
||||||
|
spawns,
|
||||||
|
playerGuid: () => player,
|
||||||
|
playerYawRadians: () => 0f,
|
||||||
|
playerCellId: () => 0xA9B40001u,
|
||||||
|
selectedGuid: () => hiddenTarget,
|
||||||
|
coordinatesOnRadar: () => true,
|
||||||
|
uiLocked: () => false,
|
||||||
|
playerEntities: canonical);
|
||||||
|
|
||||||
|
var snapshot = provider.BuildSnapshot();
|
||||||
|
|
||||||
|
Assert.NotNull(snapshot.CoordinatesText);
|
||||||
|
Assert.Empty(snapshot.Blips);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new()
|
private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new()
|
||||||
{
|
{
|
||||||
Id = guid,
|
Id = guid,
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,10 @@ public sealed class InboundPhysicsStateControllerTests
|
||||||
liveVelocity,
|
liveVelocity,
|
||||||
out PositionTimestampDisposition normalDisposition,
|
out PositionTimestampDisposition normalDisposition,
|
||||||
out WorldSession.EntitySpawn normal,
|
out WorldSession.EntitySpawn normal,
|
||||||
out _));
|
out AcceptedPhysicsTimestamps normalTimestamps));
|
||||||
Assert.Equal(PositionTimestampDisposition.Apply, normalDisposition);
|
Assert.Equal(PositionTimestampDisposition.Apply, normalDisposition);
|
||||||
Assert.Equal(liveVelocity, normal.Physics!.Value.Velocity);
|
Assert.Equal(liveVelocity, normal.Physics!.Value.Velocity);
|
||||||
|
Assert.False(normalTimestamps.TeleportAdvanced);
|
||||||
|
|
||||||
Assert.True(controller.TryApplyPosition(
|
Assert.True(controller.TryApplyPosition(
|
||||||
new WorldSession.EntityPositionUpdate(
|
new WorldSession.EntityPositionUpdate(
|
||||||
|
|
@ -200,9 +201,10 @@ public sealed class InboundPhysicsStateControllerTests
|
||||||
liveVelocity,
|
liveVelocity,
|
||||||
out PositionTimestampDisposition teleportDisposition,
|
out PositionTimestampDisposition teleportDisposition,
|
||||||
out WorldSession.EntitySpawn teleported,
|
out WorldSession.EntitySpawn teleported,
|
||||||
out _));
|
out AcceptedPhysicsTimestamps teleportTimestamps));
|
||||||
Assert.Equal(PositionTimestampDisposition.Apply, teleportDisposition);
|
Assert.Equal(PositionTimestampDisposition.Apply, teleportDisposition);
|
||||||
Assert.Equal(Vector3.Zero, teleported.Physics!.Value.Velocity);
|
Assert.Equal(Vector3.Zero, teleported.Physics!.Value.Velocity);
|
||||||
|
Assert.True(teleportTimestamps.TeleportAdvanced);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
using System.Numerics;
|
||||||
|
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;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.World;
|
||||||
|
|
||||||
|
public sealed class LiveEntityPresentationControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void InitialVisibleObject_DoesNotPlayUnHide()
|
||||||
|
{
|
||||||
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||||
|
|
||||||
|
Assert.Empty(fixture.TypedPlays);
|
||||||
|
Assert.True(fixture.Entity.IsDrawVisible);
|
||||||
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HiddenThenVisible_PlaysTypedEffectsMutatesChildrenAndRestoresShadow()
|
||||||
|
{
|
||||||
|
Fixture fixture = new(
|
||||||
|
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions);
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[(fixture.Entity.Id, LiveEntityPresentationController.HiddenScriptType, 1f)],
|
||||||
|
fixture.TypedPlays);
|
||||||
|
Assert.Equal([(Fixture.Guid, true)], fixture.ChildNoDraw);
|
||||||
|
Assert.Equal([Fixture.Guid], fixture.InvalidTargets);
|
||||||
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
||||||
|
Assert.False(fixture.Entity.IsDrawVisible);
|
||||||
|
|
||||||
|
Assert.True(fixture.Runtime.TryApplyState(
|
||||||
|
new SetState.Parsed(Fixture.Guid, 0u, 1, 2),
|
||||||
|
out _,
|
||||||
|
out _));
|
||||||
|
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
(fixture.Entity.Id, LiveEntityPresentationController.HiddenScriptType, 1f),
|
||||||
|
(fixture.Entity.Id, LiveEntityPresentationController.UnHideScriptType, 1f),
|
||||||
|
],
|
||||||
|
fixture.TypedPlays);
|
||||||
|
Assert.Equal([(Fixture.Guid, true), (Fixture.Guid, false)], fixture.ChildNoDraw);
|
||||||
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||||
|
Assert.True(fixture.Entity.IsDrawVisible);
|
||||||
|
Assert.Same(fixture.Entity, Assert.Single(fixture.Runtime.WorldEntities).Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IdenticalOrStaleState_DoesNotReplayHiddenEffect()
|
||||||
|
{
|
||||||
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||||
|
fixture.Controller.OnLiveEntityReady(Fixture.Guid);
|
||||||
|
var hidden = new SetState.Parsed(
|
||||||
|
Fixture.Guid,
|
||||||
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
||||||
|
1,
|
||||||
|
2);
|
||||||
|
|
||||||
|
Assert.True(fixture.Runtime.TryApplyState(hidden, out _, out _));
|
||||||
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
||||||
|
Assert.False(fixture.Runtime.TryApplyState(hidden, out _, out _));
|
||||||
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
||||||
|
|
||||||
|
Assert.Single(fixture.TypedPlays);
|
||||||
|
Assert.Equal(LiveEntityPresentationController.HiddenScriptType,
|
||||||
|
fixture.TypedPlays[0].Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns()
|
||||||
|
{
|
||||||
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||||
|
fixture.Controller.OnLiveEntityReady(Fixture.Guid);
|
||||||
|
|
||||||
|
Assert.True(fixture.Runtime.TryApplyState(
|
||||||
|
new SetState.Parsed(
|
||||||
|
Fixture.Guid,
|
||||||
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
||||||
|
1,
|
||||||
|
2),
|
||||||
|
out _,
|
||||||
|
out _));
|
||||||
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
||||||
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
||||||
|
|
||||||
|
Assert.True(fixture.Runtime.RebucketLiveEntity(Fixture.Guid, 0x02020001u));
|
||||||
|
Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
|
||||||
|
|
||||||
|
Assert.True(fixture.Runtime.TryApplyState(
|
||||||
|
new SetState.Parsed(Fixture.Guid, 0u, 1, 3),
|
||||||
|
out _,
|
||||||
|
out _));
|
||||||
|
fixture.Controller.OnStateAccepted(Fixture.Guid);
|
||||||
|
Assert.Equal(0, fixture.Shadows.TotalRegistered);
|
||||||
|
|
||||||
|
fixture.Spatial.AddLandblock(new LoadedLandblock(
|
||||||
|
0x0202FFFFu,
|
||||||
|
new LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>()));
|
||||||
|
|
||||||
|
Assert.True(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
|
||||||
|
Assert.Equal(1, fixture.Shadows.TotalRegistered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset()
|
||||||
|
{
|
||||||
|
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
|
||||||
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||||
|
Assert.True(fixture.Controller.BeginAuthoritativePlacement(Fixture.Guid, 1));
|
||||||
|
Assert.True(fixture.Controller.HasActivePlacement(Fixture.Guid));
|
||||||
|
Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out LiveEntityRecord oldRecord));
|
||||||
|
|
||||||
|
fixture.Controller.Forget(oldRecord);
|
||||||
|
Assert.True(fixture.Runtime.UnregisterLiveEntity(
|
||||||
|
new DeleteObject.Parsed(Fixture.Guid, 1),
|
||||||
|
isLocalPlayer: false));
|
||||||
|
Assert.False(fixture.Controller.HasActivePlacement(Fixture.Guid));
|
||||||
|
|
||||||
|
fixture.Runtime.RegisterLiveEntity(Fixture.Spawn(
|
||||||
|
PhysicsStateFlags.ReportCollisions,
|
||||||
|
instanceSequence: 2));
|
||||||
|
fixture.Runtime.MaterializeLiveEntity(
|
||||||
|
Fixture.Guid,
|
||||||
|
0x01010001u,
|
||||||
|
id => new WorldEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ServerGuid = Fixture.Guid,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = new Vector3(10f, 10f, 5f),
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = Array.Empty<MeshRef>(),
|
||||||
|
});
|
||||||
|
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
|
||||||
|
|
||||||
|
Assert.False(fixture.Controller.CompleteAuthoritativePlacement(
|
||||||
|
Fixture.Guid,
|
||||||
|
generation: 1,
|
||||||
|
deferShadowRestore: true));
|
||||||
|
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||||
|
Assert.True(fixture.Controller.BeginAuthoritativePlacement(Fixture.Guid, 2));
|
||||||
|
Assert.True(fixture.Controller.HasActivePlacement(Fixture.Guid));
|
||||||
|
|
||||||
|
fixture.Controller.Clear();
|
||||||
|
|
||||||
|
Assert.False(fixture.Controller.HasActivePlacement(Fixture.Guid));
|
||||||
|
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Fixture
|
||||||
|
{
|
||||||
|
public const uint Guid = 0x70000051u;
|
||||||
|
public List<(uint Owner, uint Type, float Intensity)> TypedPlays { get; } = [];
|
||||||
|
public List<(uint Parent, bool NoDraw)> ChildNoDraw { get; } = [];
|
||||||
|
public List<uint> InvalidTargets { get; } = [];
|
||||||
|
public GpuWorldState Spatial { get; }
|
||||||
|
public LiveEntityRuntime Runtime { get; }
|
||||||
|
public ShadowObjectRegistry Shadows { get; } = new();
|
||||||
|
public WorldEntity Entity { get; }
|
||||||
|
public LiveEntityPresentationController Controller { get; }
|
||||||
|
|
||||||
|
public Fixture(PhysicsStateFlags initialState)
|
||||||
|
{
|
||||||
|
Spatial = new GpuWorldState();
|
||||||
|
Spatial.AddLandblock(new LoadedLandblock(
|
||||||
|
0x0101FFFFu,
|
||||||
|
new LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>()));
|
||||||
|
Runtime = new LiveEntityRuntime(
|
||||||
|
Spatial,
|
||||||
|
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
|
||||||
|
WorldSession.EntitySpawn spawn = Spawn(initialState);
|
||||||
|
Runtime.RegisterLiveEntity(spawn);
|
||||||
|
Entity = Runtime.MaterializeLiveEntity(
|
||||||
|
Guid,
|
||||||
|
0x01010001u,
|
||||||
|
id => new WorldEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ServerGuid = Guid,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = new Vector3(10f, 10f, 5f),
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = Array.Empty<MeshRef>(),
|
||||||
|
})!;
|
||||||
|
Shadows.Register(
|
||||||
|
Entity.Id,
|
||||||
|
0x01000001u,
|
||||||
|
Entity.Position,
|
||||||
|
Entity.Rotation,
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0x01010000u,
|
||||||
|
state: (uint)initialState,
|
||||||
|
seedCellId: 0x01010001u);
|
||||||
|
|
||||||
|
Controller = new LiveEntityPresentationController(
|
||||||
|
Runtime,
|
||||||
|
Shadows,
|
||||||
|
(owner, type, intensity) =>
|
||||||
|
{
|
||||||
|
TypedPlays.Add((owner, type, intensity));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
(parent, noDraw) => ChildNoDraw.Add((parent, noDraw)),
|
||||||
|
InvalidTargets.Add,
|
||||||
|
() => (1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static WorldSession.EntitySpawn Spawn(
|
||||||
|
PhysicsStateFlags state,
|
||||||
|
ushort instanceSequence = 1)
|
||||||
|
{
|
||||||
|
var position = new CreateObject.ServerPosition(
|
||||||
|
0x01010001u, 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,
|
||||||
|
"fixture",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
MotionTableId: 0x09000001u,
|
||||||
|
PhysicsState: (uint)state,
|
||||||
|
InstanceSequence: instanceSequence,
|
||||||
|
MovementSequence: 1,
|
||||||
|
ServerControlSequence: 1,
|
||||||
|
PositionSequence: 1,
|
||||||
|
Physics: physics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,11 @@ public sealed class LiveEntityRuntimeTests
|
||||||
|
|
||||||
private sealed class EffectProfile : ILiveEntityEffectProfile { }
|
private sealed class EffectProfile : ILiveEntityEffectProfile { }
|
||||||
|
|
||||||
|
private sealed class RemoteMotionRuntime : ILiveEntityRemoteMotionRuntime
|
||||||
|
{
|
||||||
|
public PhysicsBody Body { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
|
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
|
||||||
{
|
{
|
||||||
public int RegisterCount { get; private set; }
|
public int RegisterCount { get; private set; }
|
||||||
|
|
@ -73,6 +78,8 @@ public sealed class LiveEntityRuntimeTests
|
||||||
spawn.Position!.Value.LandblockId,
|
spawn.Position!.Value.LandblockId,
|
||||||
id => Entity(id, spawn.Guid));
|
id => Entity(id, spawn.Guid));
|
||||||
runtime.SetAnimationRuntime(spawn.Guid, new AnimationRuntime(entity!));
|
runtime.SetAnimationRuntime(spawn.Guid, new AnimationRuntime(entity!));
|
||||||
|
var visibilityEdges = new List<bool>();
|
||||||
|
runtime.ProjectionVisibilityChanged += (_, visible) => visibilityEdges.Add(visible);
|
||||||
|
|
||||||
Assert.True(registered.LogicalRegistrationCreated);
|
Assert.True(registered.LogicalRegistrationCreated);
|
||||||
Assert.Equal(1, resources.RegisterCount);
|
Assert.Equal(1, resources.RegisterCount);
|
||||||
|
|
@ -90,6 +97,7 @@ public sealed class LiveEntityRuntimeTests
|
||||||
Assert.Single(spatial.Entities);
|
Assert.Single(spatial.Entities);
|
||||||
Assert.Equal(1, resources.RegisterCount);
|
Assert.Equal(1, resources.RegisterCount);
|
||||||
Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _));
|
Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _));
|
||||||
|
Assert.Equal(new[] { false, true }, visibilityEdges);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -296,6 +304,7 @@ public sealed class LiveEntityRuntimeTests
|
||||||
Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved));
|
Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved));
|
||||||
Assert.Same(attached, resolved);
|
Assert.Same(attached, resolved);
|
||||||
Assert.Equal(1, resources.RegisterCount);
|
Assert.Equal(1, resources.RegisterCount);
|
||||||
|
attached.IsAncestorDrawVisible = false;
|
||||||
|
|
||||||
WorldEntity same = runtime.MaterializeLiveEntity(
|
WorldEntity same = runtime.MaterializeLiveEntity(
|
||||||
spawn.Guid,
|
spawn.Guid,
|
||||||
|
|
@ -304,6 +313,7 @@ public sealed class LiveEntityRuntimeTests
|
||||||
LiveEntityProjectionKind.World)!;
|
LiveEntityProjectionKind.World)!;
|
||||||
|
|
||||||
Assert.Same(attached, same);
|
Assert.Same(attached, same);
|
||||||
|
Assert.True(same.IsAncestorDrawVisible);
|
||||||
Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value);
|
Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value);
|
||||||
Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
|
Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
|
||||||
Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
|
Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
|
||||||
|
|
@ -500,6 +510,210 @@ public sealed class LiveEntityRuntimeTests
|
||||||
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder()
|
||||||
|
{
|
||||||
|
const uint stateBeforeBindGuid = 0x70000037u;
|
||||||
|
const uint bindBeforeStateGuid = 0x70000038u;
|
||||||
|
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
|
||||||
|
runtime.RegisterLiveEntity(Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u));
|
||||||
|
runtime.RegisterLiveEntity(Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u));
|
||||||
|
|
||||||
|
PhysicsStateFlags firstState = PhysicsStateFlags.Hidden
|
||||||
|
| PhysicsStateFlags.Gravity
|
||||||
|
| PhysicsStateFlags.ReportCollisions;
|
||||||
|
Assert.True(runtime.TryApplyState(
|
||||||
|
new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2),
|
||||||
|
out _));
|
||||||
|
var lateBody = new RemoteMotionRuntime();
|
||||||
|
runtime.SetRemoteMotionRuntime(stateBeforeBindGuid, lateBody);
|
||||||
|
|
||||||
|
var earlyBody = new RemoteMotionRuntime();
|
||||||
|
runtime.SetRemoteMotionRuntime(bindBeforeStateGuid, earlyBody);
|
||||||
|
PhysicsStateFlags secondState = PhysicsStateFlags.Static
|
||||||
|
| PhysicsStateFlags.Ethereal
|
||||||
|
| PhysicsStateFlags.NoDraw;
|
||||||
|
Assert.True(runtime.TryApplyState(
|
||||||
|
new SetState.Parsed(bindBeforeStateGuid, (uint)secondState, 1, 2),
|
||||||
|
out _));
|
||||||
|
|
||||||
|
Assert.Equal((firstState & ~PhysicsStateFlags.ReportCollisions)
|
||||||
|
| PhysicsStateFlags.IgnoreCollisions,
|
||||||
|
lateBody.Body.State);
|
||||||
|
Assert.Equal(secondState, earlyBody.Body.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParentDrivenChildNoDrawKeepsBoundBodyInCanonicalState()
|
||||||
|
{
|
||||||
|
const uint guid = 0x70000039u;
|
||||||
|
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
|
||||||
|
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||||
|
var remote = new RemoteMotionRuntime();
|
||||||
|
runtime.SetRemoteMotionRuntime(guid, remote);
|
||||||
|
|
||||||
|
Assert.True(runtime.SetAttachedChildNoDraw(guid, true));
|
||||||
|
Assert.True(remote.Body.State.HasFlag(PhysicsStateFlags.NoDraw));
|
||||||
|
|
||||||
|
Assert.True(runtime.SetAttachedChildNoDraw(guid, false));
|
||||||
|
Assert.False(remote.Body.State.HasFlag(PhysicsStateFlags.NoDraw));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitiallyVisibleCreate_DoesNotManufactureUnHideTransition()
|
||||||
|
{
|
||||||
|
const uint guid = 0x70000040u;
|
||||||
|
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
|
||||||
|
|
||||||
|
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
|
||||||
|
|
||||||
|
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||||
|
Assert.True(record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition));
|
||||||
|
Assert.Equal(RetailHiddenTransition.None, transition.HiddenTransition);
|
||||||
|
Assert.Equal(PhysicsStateFlags.ReportCollisions, record.FinalPhysicsState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HiddenCreate_SuppressesMeshInteractionButRetainsNarrowObjectTick()
|
||||||
|
{
|
||||||
|
const uint guid = 0x70000041u;
|
||||||
|
var spatial = new GpuWorldState();
|
||||||
|
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||||
|
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||||
|
runtime.RegisterLiveEntity(Spawn(
|
||||||
|
guid,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0x01010001u,
|
||||||
|
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions));
|
||||||
|
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||||
|
guid,
|
||||||
|
0x01010001u,
|
||||||
|
id => Entity(id, guid))!;
|
||||||
|
|
||||||
|
Assert.False(entity.IsDrawVisible);
|
||||||
|
Assert.Empty(runtime.WorldEntities);
|
||||||
|
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
|
||||||
|
Assert.Single(spatial.Entities);
|
||||||
|
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
|
||||||
|
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||||
|
Assert.Equal(PhysicsStateFlags.None,
|
||||||
|
record.FinalPhysicsState & PhysicsStateFlags.ReportCollisions);
|
||||||
|
Assert.NotEqual(PhysicsStateFlags.None,
|
||||||
|
record.FinalPhysicsState & PhysicsStateFlags.IgnoreCollisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HiddenThenVisibleState_RestoresSameMeshAndInteractionIdentity()
|
||||||
|
{
|
||||||
|
const uint guid = 0x70000042u;
|
||||||
|
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))!;
|
||||||
|
|
||||||
|
Assert.True(runtime.TryApplyState(new SetState.Parsed(
|
||||||
|
guid,
|
||||||
|
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
|
||||||
|
1,
|
||||||
|
2), out _, out RetailPhysicsStateTransition hidden));
|
||||||
|
Assert.Equal(RetailHiddenTransition.BecameHidden, hidden.HiddenTransition);
|
||||||
|
Assert.False(entity.IsDrawVisible);
|
||||||
|
Assert.Empty(runtime.WorldEntities);
|
||||||
|
Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _));
|
||||||
|
|
||||||
|
Assert.True(runtime.TryApplyState(new SetState.Parsed(
|
||||||
|
guid,
|
||||||
|
0u,
|
||||||
|
1,
|
||||||
|
3), out _, out RetailPhysicsStateTransition visible));
|
||||||
|
Assert.Equal(RetailHiddenTransition.BecameVisible, visible.HiddenTransition);
|
||||||
|
Assert.True(entity.IsDrawVisible);
|
||||||
|
Assert.True(runtime.TryGetInteractionEligibleEntity(guid, out var eligible));
|
||||||
|
Assert.Same(entity, eligible);
|
||||||
|
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
|
||||||
|
Assert.Same(entity, Assert.Single(spatial.Entities));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp()
|
||||||
|
{
|
||||||
|
const uint guid = 0x70000043u;
|
||||||
|
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
|
||||||
|
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||||
|
runtime.RegisterLiveEntity(spawn);
|
||||||
|
Assert.True(runtime.TryApplyPickup(
|
||||||
|
new PickupEvent.Parsed(guid, 1, 2),
|
||||||
|
out _));
|
||||||
|
|
||||||
|
var update = new WorldSession.EntityPositionUpdate(
|
||||||
|
guid,
|
||||||
|
new CreateObject.ServerPosition(
|
||||||
|
0x01010002u, 12f, 13f, 5f, 1f, 0f, 0f, 0f),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
0);
|
||||||
|
|
||||||
|
Assert.True(runtime.TryApplyPosition(
|
||||||
|
update,
|
||||||
|
isLocalPlayer: false,
|
||||||
|
forcePositionRotation: null,
|
||||||
|
currentLocalVelocity: null,
|
||||||
|
out PositionTimestampDisposition disposition,
|
||||||
|
out _,
|
||||||
|
out AcceptedPhysicsTimestamps timestamps));
|
||||||
|
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||||
|
Assert.False(timestamps.TeleportAdvanced);
|
||||||
|
Assert.True(timestamps.TeleportHookRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PositionFromPendingProjection_RequiresTeleportHookWithEqualTeleportStamp()
|
||||||
|
{
|
||||||
|
const uint guid = 0x70000044u;
|
||||||
|
var spatial = new GpuWorldState();
|
||||||
|
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||||
|
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
|
||||||
|
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||||
|
runtime.RegisterLiveEntity(spawn);
|
||||||
|
runtime.MaterializeLiveEntity(
|
||||||
|
guid,
|
||||||
|
0x01010001u,
|
||||||
|
id => Entity(id, guid));
|
||||||
|
spatial.RemoveLandblock(0x0101FFFFu);
|
||||||
|
|
||||||
|
Assert.True(runtime.TryApplyPosition(
|
||||||
|
new WorldSession.EntityPositionUpdate(
|
||||||
|
guid,
|
||||||
|
new CreateObject.ServerPosition(
|
||||||
|
0x01010002u, 12f, 13f, 5f, 1f, 0f, 0f, 0f),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
0),
|
||||||
|
isLocalPlayer: false,
|
||||||
|
forcePositionRotation: null,
|
||||||
|
currentLocalVelocity: null,
|
||||||
|
out PositionTimestampDisposition disposition,
|
||||||
|
out _,
|
||||||
|
out AcceptedPhysicsTimestamps timestamps));
|
||||||
|
|
||||||
|
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||||
|
Assert.False(timestamps.TeleportAdvanced);
|
||||||
|
Assert.True(timestamps.TeleportHookRequired);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity()
|
public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity()
|
||||||
{
|
{
|
||||||
|
|
@ -792,14 +1006,15 @@ public sealed class LiveEntityRuntimeTests
|
||||||
uint guid,
|
uint guid,
|
||||||
ushort instance,
|
ushort instance,
|
||||||
ushort positionSequence,
|
ushort positionSequence,
|
||||||
uint cell)
|
uint cell,
|
||||||
|
PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions)
|
||||||
{
|
{
|
||||||
var position = new CreateObject.ServerPosition(
|
var position = new CreateObject.ServerPosition(
|
||||||
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||||
var timestamps = new PhysicsTimestamps(
|
var timestamps = new PhysicsTimestamps(
|
||||||
positionSequence, 1, 1, 1, 0, 1, 0, 1, instance);
|
positionSequence, 1, 1, 1, 0, 1, 0, 1, instance);
|
||||||
var physics = new PhysicsSpawnData(
|
var physics = new PhysicsSpawnData(
|
||||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
RawState: (uint)state,
|
||||||
Position: position,
|
Position: position,
|
||||||
Movement: null,
|
Movement: null,
|
||||||
AnimationFrame: null,
|
AnimationFrame: null,
|
||||||
|
|
@ -832,7 +1047,7 @@ public sealed class LiveEntityRuntimeTests
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
0x09000001u,
|
0x09000001u,
|
||||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
PhysicsState: (uint)state,
|
||||||
InstanceSequence: instance,
|
InstanceSequence: instance,
|
||||||
MovementSequence: 1,
|
MovementSequence: 1,
|
||||||
ServerControlSequence: 1,
|
ServerControlSequence: 1,
|
||||||
|
|
|
||||||
|
|
@ -145,4 +145,62 @@ public class HandleAllCollisionsTests
|
||||||
// dot = -5 < 0 → k = -(-5*1.05) = 5.25 → v.z = -5 + 5.25 = 0.25 (tiny bounce)
|
// dot = -5 < 0 → k = -(-5*1.05) = 5.25 → v.z = -5 + 5.25 = 0.25 (tiny bounce)
|
||||||
Assert.Equal(0.25f, b.Velocity.Z, precision: 3);
|
Assert.Equal(0.25f, b.Velocity.Z, precision: 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetPositionTransition_HiddenLandingCallbackRunsBeforeCollisionResponse()
|
||||||
|
{
|
||||||
|
var body = Airborne(Vector3.Zero);
|
||||||
|
int hitGroundCalls = 0;
|
||||||
|
|
||||||
|
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||||
|
body,
|
||||||
|
inContact: true,
|
||||||
|
onWalkable: true,
|
||||||
|
collisionNormalValid: true,
|
||||||
|
collisionNormal: Vector3.UnitZ,
|
||||||
|
previousContact: false,
|
||||||
|
previousOnWalkable: false,
|
||||||
|
hitGround: () =>
|
||||||
|
{
|
||||||
|
hitGroundCalls++;
|
||||||
|
body.Velocity = new Vector3(0f, 0f, -4f);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(1, hitGroundCalls);
|
||||||
|
Assert.True(body.InContact);
|
||||||
|
Assert.True(body.OnWalkable);
|
||||||
|
// Callback -4 then retail elasticity 0.05 reflection => +0.2.
|
||||||
|
// Running the callback after collision response would leave -4.
|
||||||
|
Assert.Equal(0.2f, body.Velocity.Z, precision: 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetPositionTransition_HiddenWalkOffCallbackRunsBeforeCollisionResponse()
|
||||||
|
{
|
||||||
|
var body = Airborne(Vector3.Zero);
|
||||||
|
body.TransientState = TransientStateFlags.Contact
|
||||||
|
| TransientStateFlags.OnWalkable;
|
||||||
|
int leaveGroundCalls = 0;
|
||||||
|
|
||||||
|
PhysicsObjUpdate.CommitSetPositionTransition(
|
||||||
|
body,
|
||||||
|
inContact: false,
|
||||||
|
onWalkable: false,
|
||||||
|
collisionNormalValid: true,
|
||||||
|
collisionNormal: -Vector3.UnitX,
|
||||||
|
previousContact: true,
|
||||||
|
previousOnWalkable: true,
|
||||||
|
leaveGround: () =>
|
||||||
|
{
|
||||||
|
leaveGroundCalls++;
|
||||||
|
body.Velocity = new Vector3(3f, 0f, 0f);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(1, leaveGroundCalls);
|
||||||
|
Assert.False(body.InContact);
|
||||||
|
Assert.False(body.OnWalkable);
|
||||||
|
// Callback +3 into a -X normal then reflection => -0.15.
|
||||||
|
// Running the callback after response would leave +3.
|
||||||
|
Assert.Equal(-0.15f, body.Velocity.X, precision: 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ public sealed class InitialPlacementOverlapTests
|
||||||
movingEntityId: PlayerId);
|
movingEntityId: PlayerId);
|
||||||
|
|
||||||
Assert.True(result.Ok);
|
Assert.True(result.Ok);
|
||||||
|
Assert.True(result.InContact);
|
||||||
|
Assert.True(result.OnWalkable);
|
||||||
|
Assert.Equal(Cell, result.ContactPlaneCellId);
|
||||||
Assert.NotEqual(savedFeet, result.Position);
|
Assert.NotEqual(savedFeet, result.Position);
|
||||||
float centerDistance = Vector3.Distance(
|
float centerDistance = Vector3.Distance(
|
||||||
result.Position + new Vector3(0f, 0f, Radius),
|
result.Position + new Vector3(0f, 0f, Radius),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
public sealed class RetailPhysicsStateTransitionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ConstructorState_MatchesRetailAndIsNotHidden()
|
||||||
|
{
|
||||||
|
Assert.Equal(0x00400C08u, (uint)RetailPhysicsStateTransitions.ConstructorState);
|
||||||
|
Assert.Equal(
|
||||||
|
PhysicsStateFlags.None,
|
||||||
|
RetailPhysicsStateTransitions.ConstructorState & PhysicsStateFlags.Hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VisibleToHidden_PlaysHiddenShapeAndRewritesCollisionBits()
|
||||||
|
{
|
||||||
|
PhysicsStateFlags requested = PhysicsStateFlags.Hidden
|
||||||
|
| PhysicsStateFlags.ReportCollisions
|
||||||
|
| PhysicsStateFlags.Lighting;
|
||||||
|
|
||||||
|
RetailPhysicsStateTransition result = RetailPhysicsStateTransitions.Apply(
|
||||||
|
RetailPhysicsStateTransitions.ConstructorState,
|
||||||
|
requested);
|
||||||
|
|
||||||
|
Assert.Equal(RetailHiddenTransition.BecameHidden, result.HiddenTransition);
|
||||||
|
Assert.False(result.LightingChanged);
|
||||||
|
Assert.Equal(PhysicsStateFlags.None, result.FinalState & PhysicsStateFlags.ReportCollisions);
|
||||||
|
Assert.NotEqual(PhysicsStateFlags.None, result.FinalState & PhysicsStateFlags.IgnoreCollisions);
|
||||||
|
Assert.NotEqual(PhysicsStateFlags.None, result.FinalState & PhysicsStateFlags.Hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HiddenToVisible_RestoresReportCollisionsEvenWhenWireOmitsIt()
|
||||||
|
{
|
||||||
|
PhysicsStateFlags hidden = PhysicsStateFlags.Hidden
|
||||||
|
| PhysicsStateFlags.IgnoreCollisions;
|
||||||
|
|
||||||
|
RetailPhysicsStateTransition result = RetailPhysicsStateTransitions.Apply(
|
||||||
|
hidden,
|
||||||
|
PhysicsStateFlags.None);
|
||||||
|
|
||||||
|
Assert.Equal(RetailHiddenTransition.BecameVisible, result.HiddenTransition);
|
||||||
|
Assert.Equal(PhysicsStateFlags.None, result.FinalState & PhysicsStateFlags.Hidden);
|
||||||
|
Assert.Equal(PhysicsStateFlags.None, result.FinalState & PhysicsStateFlags.IgnoreCollisions);
|
||||||
|
Assert.NotEqual(PhysicsStateFlags.None, result.FinalState & PhysicsStateFlags.ReportCollisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IdenticalHiddenState_DoesNotReplayTransition()
|
||||||
|
{
|
||||||
|
PhysicsStateFlags state = PhysicsStateFlags.Hidden
|
||||||
|
| PhysicsStateFlags.IgnoreCollisions;
|
||||||
|
|
||||||
|
RetailPhysicsStateTransition result = RetailPhysicsStateTransitions.Apply(state, state);
|
||||||
|
|
||||||
|
Assert.Equal(RetailHiddenTransition.None, result.HiddenTransition);
|
||||||
|
Assert.False(result.LightingChanged);
|
||||||
|
Assert.False(result.NoDrawChanged);
|
||||||
|
Assert.Equal(state, result.FinalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LightingNoDrawAndHiddenChangeFlags_AreDerivedFromLowWord()
|
||||||
|
{
|
||||||
|
RetailPhysicsStateTransition result = RetailPhysicsStateTransitions.Apply(
|
||||||
|
PhysicsStateFlags.None,
|
||||||
|
PhysicsStateFlags.Lighting
|
||||||
|
| PhysicsStateFlags.NoDraw
|
||||||
|
| PhysicsStateFlags.Hidden);
|
||||||
|
|
||||||
|
Assert.True(result.LightingChanged);
|
||||||
|
Assert.True(result.NoDrawChanged);
|
||||||
|
Assert.Equal(RetailHiddenTransition.BecameHidden, result.HiddenTransition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -102,6 +102,46 @@ public sealed class WbDrawDispatcherBucketingTests
|
||||||
Assert.Empty(result.ToDraw);
|
Assert.Empty(result.ToDraw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WalkEntities_NoDrawHiddenOrSuppressedAncestor_SkipsMeshWithoutRemovingEntity()
|
||||||
|
{
|
||||||
|
WorldEntity suppressed = MakeEntityWithMesh(7u, Vector3.Zero);
|
||||||
|
suppressed.IsDrawVisible = false;
|
||||||
|
var entities = new[] { suppressed };
|
||||||
|
var entries = new[]
|
||||||
|
{
|
||||||
|
new WbDrawDispatcher.LandblockEntry(
|
||||||
|
0xAAAA_FFFFu,
|
||||||
|
new Vector3(-10f),
|
||||||
|
new Vector3(10f),
|
||||||
|
entities,
|
||||||
|
BuildById(entities)),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = WbDrawDispatcher.WalkEntities(
|
||||||
|
entries,
|
||||||
|
frustum: null,
|
||||||
|
neverCullLandblockId: null,
|
||||||
|
visibleCellIds: null,
|
||||||
|
animatedEntityIds: new HashSet<uint> { suppressed.Id });
|
||||||
|
|
||||||
|
Assert.Equal(0, result.EntitiesWalked);
|
||||||
|
Assert.Empty(result.ToDraw);
|
||||||
|
Assert.Same(suppressed, Assert.Single(entries[0].Entities));
|
||||||
|
|
||||||
|
suppressed.IsDrawVisible = true;
|
||||||
|
suppressed.IsAncestorDrawVisible = false;
|
||||||
|
result = WbDrawDispatcher.WalkEntities(
|
||||||
|
entries,
|
||||||
|
frustum: null,
|
||||||
|
neverCullLandblockId: null,
|
||||||
|
visibleCellIds: null,
|
||||||
|
animatedEntityIds: new HashSet<uint> { suppressed.Id });
|
||||||
|
|
||||||
|
Assert.Equal(0, result.EntitiesWalked);
|
||||||
|
Assert.Empty(result.ToDraw);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities()
|
public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue