From 1e98d814489df04ba73dd8e1eb395b1174af2c03 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Jul 2026 14:59:48 +0200 Subject: [PATCH] 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. --- docs/architecture/acdream-architecture.md | 48 +- docs/architecture/code-structure.md | 43 +- .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 1 + docs/plans/2026-05-12-milestones.md | 15 +- ...-07-13-retail-projectile-vfx-pseudocode.md | 106 +- .../Input/PlayerMovementController.cs | 91 ++ .../Physics/ProjectileController.cs | 47 +- .../Physics/RemotePhysicsUpdater.cs | 154 +- .../Physics/RemoteTeleportController.cs | 444 ++++++ src/AcDream.App/Physics/RemoteTeleportHook.cs | 39 + .../Physics/RemoteTeleportPlacement.cs | 77 + .../Rendering/EntityPhysicsHost.cs | 13 + .../EquippedChildRenderController.cs | 36 + src/AcDream.App/Rendering/GameWindow.cs | 349 ++++- .../Rendering/Vfx/EntityEffectController.cs | 23 +- .../Rendering/Wb/WbDrawDispatcher.cs | 2 + src/AcDream.App/Streaming/GpuWorldState.cs | 55 +- .../UI/Layout/RadarSnapshotProvider.cs | 7 +- .../World/InboundPhysicsStateController.cs | 16 +- .../World/LiveEntityPresentationController.cs | 282 ++++ src/AcDream.App/World/LiveEntityRuntime.cs | 223 ++- src/AcDream.App/World/LiveEntityTeardown.cs | 33 + .../Physics/Motion/TargetManager.cs | 18 + src/AcDream.Core/Physics/PhysicsEngine.cs | 14 +- src/AcDream.Core/Physics/PhysicsObjUpdate.cs | 64 + src/AcDream.Core/Physics/ResolveResult.cs | 8 +- .../Physics/RetailPhysicsStateTransition.cs | 89 ++ src/AcDream.Core/World/WorldEntity.cs | 16 + .../Physics/PlayerMovementHiddenTests.cs | 82 ++ .../Physics/ProjectileControllerTests.cs | 90 ++ .../Physics/RemotePhysicsUpdaterTests.cs | 166 +++ .../Physics/RemoteTeleportControllerTests.cs | 1288 +++++++++++++++++ .../Physics/RemoteTeleportHookTests.cs | 38 + .../Physics/RemoteTeleportPlacementTests.cs | 194 +++ .../Rendering/AttachmentUpdateOrderTests.cs | 32 + .../Rendering/EntityPhysicsHostTests.cs | 65 + .../Vfx/EntityEffectControllerTests.cs | 22 + .../UI/Layout/RadarSnapshotProviderTests.cs | 40 + .../InboundPhysicsStateControllerTests.cs | 6 +- .../LiveEntityPresentationControllerTests.cs | 273 ++++ .../World/LiveEntityRuntimeTests.cs | 221 ++- .../Physics/HandleAllCollisionsTests.cs | 58 + .../Physics/InitialPlacementOverlapTests.cs | 3 + .../RetailPhysicsStateTransitionTests.cs | 77 + .../Wb/WbDrawDispatcherBucketingTests.cs | 40 + 46 files changed, 4883 insertions(+), 127 deletions(-) create mode 100644 src/AcDream.App/Physics/RemoteTeleportController.cs create mode 100644 src/AcDream.App/Physics/RemoteTeleportHook.cs create mode 100644 src/AcDream.App/Physics/RemoteTeleportPlacement.cs create mode 100644 src/AcDream.App/World/LiveEntityPresentationController.cs create mode 100644 src/AcDream.App/World/LiveEntityTeardown.cs create mode 100644 src/AcDream.Core/Physics/RetailPhysicsStateTransition.cs create mode 100644 tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/RemoteTeleportHookTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/RetailPhysicsStateTransitionTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index d9accfee..6bf1b2d6 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -181,6 +181,14 @@ src/ AcDream.App/ Layer 1 + Layer 4 wiring Physics/ 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/ GameWindow.cs -> still owns too much runtime wiring TerrainRenderer.cs -> done @@ -248,6 +256,36 @@ What exists and is active: owns the frame; delete, generation replacement, pickup/parent leave-world, and session reset use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map. +- `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 the transition path. - `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 script creation. Its canonical materialized view remains stable across pending 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 storage-free typed views while its large feature loops are extracted. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 3aadfa27..2125e767 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -187,9 +187,17 @@ src/AcDream.App/ │ └── Vfx/ # (already exists) ├── Net/ │ └── 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/ │ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots │ ├── 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 ├── Interaction/ │ └── 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 latest accepted immutable CreateObject snapshot, owns the canonical local ID 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` owns the focused mixed F754/F755 pending FIFO, effect profiles, typed-table 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 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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index cac40e3e..9de7f4db 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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-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-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` | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index d13731d3..28cd1021 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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 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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 7e63bcd4..f80198f4 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -471,8 +471,8 @@ include dungeons. `PlayerDescription.Options1` preserves retail's player secure-trade preference. See `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. -- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–7 - implemented and independently reviewed 2026-07-14)** — named-retail projectile and +- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–8 + independently reviewed 2026-07-14)** — named-retail projectile and physics-script behavior is pinned in one oracle, the complete `PhysicsDesc` and F754/F755 wire surfaces are parsed with retail timestamp gates, and `LiveEntityRuntime` now separates logical lifetime from spatial rebucketing. @@ -526,8 +526,15 @@ include dungeons. if Missile classification changes while pending; static missile effect roots follow every predicted frame, and materialized rehydration never uses a stale spawn cell. No impact, damage, effect, or delete event is synthesized - client-side. The remaining steps port - Hidden/UnHide portal presentation and run final hardening/visual gates. + client-side. Step 8 ports retail's constructor-state → PhysicsDesc + 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)** — 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 diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md index f90ad6af..26a657d6 100644 --- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md +++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md @@ -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 scripts remain distinct packet-driven events. A normal visible spawn alone is not evidence for an UnHide materialization effect; the observer portal-in -sequence must be validated against its actual inbound state/effect packets -during Step 8. +sequence remains a final two-client packet/visual gate. + +### 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 diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 582b850c..61010ab6 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -149,6 +149,17 @@ public sealed class PlayerMovementController /// public uint LocalEntityId { get; set; } + /// + /// Applies the canonical server PhysicsState to the local body. Retail's + /// CPhysicsObj::SetState replaces these persistent bits before its + /// next acceleration/integration decision. + /// + public void ApplyPhysicsState(PhysicsStateFlags state) + { + _body.State = state; + _body.calc_acceleration(); + } + public bool IsAirborne => !_body.OnWalkable; /// @@ -571,6 +582,86 @@ public sealed class PlayerMovementController return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); } + /// + /// Retail Hidden slice of CPhysicsObj::UpdatePositionInternal + /// (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. + /// + 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) { _simTimeSeconds += dt; diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index f0752797..4d75dbfa 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -251,7 +251,7 @@ internal sealed class ProjectileController entity.MeshRefs.Count > 0); _liveEntities.SetProjectileRuntime(record.ServerGuid, runtime); - if (HasVisibleCell(record)) + if (HasVisibleCell(record) && !IsHidden(record)) { ShadowPositionSynchronizer.Sync( _shadows, @@ -262,6 +262,15 @@ internal sealed class ProjectileController liveCenterX, 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 { SuspendOutsideWorld(runtime, entity.Id); @@ -300,7 +309,8 @@ internal sealed class ProjectileController SetVelocity(runtime.Body, velocity, currentTime); runtime.Body.Omega = angularVelocity; - if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)) + if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid) + && !IsHidden(record)) Deactivate(runtime.Body); return true; } @@ -425,7 +435,7 @@ internal sealed class ProjectileController entity.Rotation = orientation; entity.ParentCellId = canonicalCellId; _liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId); - if (HasVisibleCell(record)) + if (HasVisibleCell(record) && !IsHidden(record)) { if (!wasInWorld) Activate(body, currentTime); @@ -439,6 +449,12 @@ internal sealed class ProjectileController liveCenterX, liveCenterY); } + else if (HasVisibleCell(record)) + { + body.InWorld = true; + body.LastUpdateTime = currentTime; + _shadows.Suspend(entity.Id); + } else { SuspendOutsideWorld(runtime, entity.Id); @@ -496,6 +512,27 @@ internal sealed class ProjectileController || record.WorldEntity is not { } entity) 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 = (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0; if (!isMissile) @@ -530,7 +567,6 @@ internal sealed class ProjectileController continue; } - runtime.Body.State = record.FinalPhysicsState; if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)) { if (!HasVisibleCell(record)) @@ -637,6 +673,9 @@ internal sealed class ProjectileController && record.IsSpatiallyVisible && record.FullCellId != 0; + private static bool IsHidden(LiveEntityRecord record) => + (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; + private void SuspendOutsideWorld(Runtime runtime, uint localEntityId) { runtime.Body.InWorld = false; diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 2aa57a71..8ee7098a 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -11,6 +11,8 @@ namespace AcDream.App.Physics; /// frame, from inside the same guard the body used to live under /// (ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid /// && rm.LastServerPosTime > 0). +/// Hidden remotes use a separate live-entity pass because retail keeps their +/// PositionManager alive even when the object has no render-animation owner. /// /// 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 @@ -55,6 +57,35 @@ internal sealed class RemotePhysicsUpdater // Duplicated one-liner (GameWindow keeps its own copy — many callers there). private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; + /// + /// Advances the retained PositionManager path for every hidden remote, + /// including objects without an AnimatedEntity (missiles commonly + /// have no PartArray). Retail gates this path on the live CPhysicsObj, not + /// on whether a render-animation owner exists. + /// + public void TickHiddenEntities( + AcDream.App.World.LiveEntityRuntime liveEntities, + uint localPlayerServerGuid, + float dt, + System.Action 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); + } + } + /// /// #184 Slice 2a — the per-remote DR tick (retail UpdateObjectInternal /// shape), verbatim from the former GameWindow.TickAnimations guard @@ -556,6 +587,99 @@ internal sealed class RemotePhysicsUpdater rm.Host?.PositionManager.UseTime(); } + /// + /// Retail hidden-object slice of CPhysicsObj::UpdatePositionInternal + /// (0x00512C30) plus the manager tail of + /// UpdateObjectInternal (0x005156B0). Hidden skips + /// CPartArray::Update and UpdatePhysicsInternal, 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. + /// + 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(); + } + /// /// R4-V5 / R5-V2: the per-tick /// drive (retail MovementManager::UseTime 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 /// UP-branch tail. /// - 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( _physicsEngine.ShadowObjects, entityId, - rm.Body.Position, - rm.Body.Orientation, - rm.CellId, + body.Position, + body.Orientation, + authoritativeCellId, liveCenterX, liveCenterY); - rm.LastShadowSyncPos = rm.Body.Position; } } diff --git a/src/AcDream.App/Physics/RemoteTeleportController.cs b/src/AcDream.App/Physics/RemoteTeleportController.cs new file mode 100644 index 00000000..d1a9a010 --- /dev/null +++ b/src/AcDream.App/Physics/RemoteTeleportController.cs @@ -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; + +/// +/// Owns the SetPosition half of retail remote +/// CPhysicsObj::MoveOrTeleport (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. +/// +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 _getSetupCylinder; + private readonly Func _cellLocalForSeed; + private readonly Action _syncResolvedShadow; + private readonly Action _completeAuthoritativePlacement; + private readonly Action _beginAuthoritativePlacement; + private readonly PlacementResolver _resolvePlacement; + private readonly Dictionary _pending = new(); + + internal RemoteTeleportController( + PhysicsEngine physics, + LiveEntityRuntime liveEntities, + Func getSetupCylinder, + Func cellLocalForSeed, + Action syncResolvedShadow, + Action completeAuthoritativePlacement, + Action 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); + + /// + /// Transfers any older deferred shadow restoration before the accepted + /// destination is rebucketed and can publish a visibility edge. + /// + 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; +} diff --git a/src/AcDream.App/Physics/RemoteTeleportHook.cs b/src/AcDream.App/Physics/RemoteTeleportHook.cs new file mode 100644 index 00000000..39b16299 --- /dev/null +++ b/src/AcDream.App/Physics/RemoteTeleportHook.cs @@ -0,0 +1,39 @@ +using AcDream.Core.Physics; + +namespace AcDream.App.Physics; + +/// +/// Ordered App seam for retail CPhysicsObj::teleport_hook +/// (0x00514ED0). The action bundle keeps the port independently +/// testable while the concrete movement/interpolation/target owners remain +/// in the composition root. +/// +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 CancelMoveTo, + Action UnStick, + Action StopInterpolating, + Action UnConstrain, + Action NotifyTeleported, + Action ReportCollisionEnd); diff --git a/src/AcDream.App/Physics/RemoteTeleportPlacement.cs b/src/AcDream.App/Physics/RemoteTeleportPlacement.cs new file mode 100644 index 00000000..33cd6c42 --- /dev/null +++ b/src/AcDream.App/Physics/RemoteTeleportPlacement.cs @@ -0,0 +1,77 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Physics; + +namespace AcDream.App.Physics; + +/// +/// Commits the placement half of retail CPhysicsObj::MoveOrTeleport +/// Branch A (0x00516330). The caller runs +/// first, then this exact frame/cell snap, +/// before considering contact-driven interpolation. +/// +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); + +} diff --git a/src/AcDream.App/Rendering/EntityPhysicsHost.cs b/src/AcDream.App/Rendering/EntityPhysicsHost.cs index 151cd0a8..3aa1d2e7 100644 --- a/src/AcDream.App/Rendering/EntityPhysicsHost.cs +++ b/src/AcDream.App/Rendering/EntityPhysicsHost.cs @@ -135,6 +135,19 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost /// registry. public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); + /// + /// A Hidden object remains logically alive but is removed from the cell + /// lookup/interaction surface. Retail's DetectionManager sends + /// LeftDetection from CObjCell::hide_object; 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. + /// + public void NotifyHidden() => + _targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld); + /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget /// (drop this entity's OWN subscription) then diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 49030e34..39668e45 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -219,6 +219,7 @@ public sealed class EquippedChildRenderController : IDisposable child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability); if (!ApplyParentWorldPose(child.Entity, parentWorld)) return false; + ApplyParentDrawVisibility(child.Entity, parent); child.Entity.ParentCellId = parent.ParentCellId; PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose); if (parent.ParentCellId is { } parentCellId) @@ -317,6 +318,7 @@ public sealed class EquippedChildRenderController : IDisposable if (entity is null) return false; ApplyParentWorldPose(entity, parentWorld); + ApplyParentDrawVisibility(entity, parentEntity); entity.ParentCellId = parentCellId; entity.ApplyAppearance( pose.AttachedParts, @@ -338,6 +340,14 @@ public sealed class EquippedChildRenderController : IDisposable pose.AttachedParts, scale, 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); Console.WriteLine( $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + @@ -376,6 +386,18 @@ public sealed class EquippedChildRenderController : IDisposable return true; } + /// + /// 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. + /// + internal static void ApplyParentDrawVisibility(WorldEntity child, WorldEntity parent) + { + child.IsAncestorDrawVisible = + parent.IsDrawVisible && parent.IsAncestorDrawVisible; + } + private static bool TryDecomposeWorldPose( Matrix4x4 world, out Vector3 position, @@ -425,6 +447,20 @@ public sealed class EquippedChildRenderController : IDisposable return null; } + /// + /// Retail CPhysicsObj::set_hidden walks the direct CHILDLIST and + /// sets/clears each child's NoDraw bit before changing root collision/cell + /// presentation. Descendants are not recursively rewritten here. + /// + 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) { Relations.Resolve( diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ff9bfc1d..e31e9323 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -159,6 +159,7 @@ public sealed class GameWindow : IDisposable // once the injected shared helpers exist as method groups (GetSetupCylinder, // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater; + private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; // each runtime component is stored on the canonical LiveEntityRecord. private AcDream.App.Physics.ProjectileController? _projectileController; @@ -460,10 +461,9 @@ public sealed class GameWindow : IDisposable /// /// internal sealed class RemoteMotion : - AcDream.App.World.ILiveEntityRemoteMotionRuntime, - AcDream.App.World.ILiveEntityCanonicalCellConsumer // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it + AcDream.App.World.ILiveEntityRemotePlacementRuntime // 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; /// R5-V5: retail CPhysicsObj::movement_manager — 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(); + /// /// K-fix9 (2026-04-26): true while the remote is airborne (jump /// 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. private AcDream.Core.Lighting.LightingHookSink? _lightingSink; private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights; + private AcDream.App.World.LiveEntityPresentationController? _liveEntityPresentation; // #188 — TransparentPartHook fires from the animation pipeline drive // a per-(entity,part) translucency ramp; WbDrawDispatcher reads it @@ -2173,7 +2204,8 @@ public sealed class GameWindow : IDisposable playerCellId: () => _playerController?.CellId ?? 0u, selectedGuid: () => _selection.SelectedObjectId, coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, - uiLocked: () => _persistedGameplay.LockUI); + uiLocked: () => _persistedGameplay.LockUI, + playerEntities: _entitiesByServerGuid); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); _retailChatVm = retailChatVm; AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null @@ -2444,7 +2476,6 @@ public sealed class GameWindow : IDisposable { DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), }; - _liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController( _liveEntities, _effectPoses, @@ -2479,9 +2510,34 @@ public sealed class GameWindow : IDisposable _entityEffects = entityEffects; entityEffects.DiagnosticSink = 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 => { - entityEffects.OnLiveEntityReady(guid); + CompleteLiveEntityReady(guid); }; _equippedChildRenderer.ProjectionPoseReady += guid => _liveEntityLights.OnAttachedPoseReady(guid); @@ -2704,16 +2760,33 @@ public sealed class GameWindow : IDisposable _equippedChildRenderer?.Clear(); Objects.Clear(); _selection.Reset(); + _pendingPostArrivalAction = null; try { _liveEntities?.Clear(); } finally { - // F754/F755 can precede CreateObject, so the mixed pending FIFO - // may contain owners with no LiveEntityRecord to tear down. - _entityEffects?.ClearNetworkState(); - _animationHookFrames?.Clear(); + try + { + // A pending teleport is operational state outside the live + // 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, // and effect profile are now all installed. Only at this boundary may // 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 // waiting for a graceful shutdown. @@ -4468,7 +4541,9 @@ public sealed class GameWindow : IDisposable if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature) 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( entityId: entity.Id, @@ -4596,6 +4671,13 @@ public sealed class GameWindow : IDisposable uint cellId, bool force = false) { + if (_liveEntities?.IsHidden(_playerServerGuid) == true) + { + _physicsEngine.ShadowObjects.Suspend(playerEntity.Id); + _lastLocalPlayerShadow = null; + return; + } + if (!force && _lastLocalPlayerShadow is { } last && last.CellId == cellId @@ -4836,17 +4918,20 @@ public sealed class GameWindow : IDisposable /// 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)) return existing; - if (!_entitiesByServerGuid.ContainsKey(id)) - return null; double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; var minimal = new EntityPhysicsHost( id, getPosition: () => new AcDream.Core.Physics.Position( 0u, - _entitiesByServerGuid.TryGetValue(id, out var e) + _visibleEntitiesByServerGuid.TryGetValue(id, out var e) ? e.Position : System.Numerics.Vector3.Zero, System.Numerics.Quaternion.Identity), getVelocity: () => System.Numerics.Vector3.Zero, // static target @@ -4914,20 +4999,63 @@ public sealed class GameWindow : IDisposable { if (host is null) return; - if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var tgtEnt)) + if (_liveEntities is not { } liveEntities + || !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt)) return; var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt); host.PositionManager.StickTo(targetGuid, radius, height); } + /// + /// Completes retail object construction in order: register the effect + /// owner, apply constructor/PhysicsDesc state transitions, then replay + /// SmartBox's queued F754/F755 blobs. + /// + 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) { - _entityEffects?.OnLiveEntityUnregistered(record); + uint serverGuid = record.ServerGuid; + var cleanups = new List + { + () => _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) + { + AcDream.App.World.LiveEntityTeardown.Run(cleanups); return; + } - uint serverGuid = record.ServerGuid; // R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains // independently (r3-port-plan §4): the manager's (each pending // 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 // relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30). 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)) - rmGone.Movement.HandleExitWorld(); + cleanups.Add(rmGone.Movement.HandleExitWorld); // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this // entity that it left the world (TargetManager::NotifyVoyeurOfEvent // 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 // two, a despawning attacker leaves a dead voyeur entry on its // target until send-failure pruning. - ephGone.PositionManager.UnStick(); - ephGone.ClearTarget(); - ephGone.NotifyExitWorld(); + cleanups.Add(ephGone.PositionManager.UnStick); + cleanups.Add(ephGone.ClearTarget); + cleanups.Add(ephGone.NotifyExitWorld); } - _physicsHosts.Remove(serverGuid); - _animatedEntities.Remove(existingEntity.Id); - _classificationCache.InvalidateEntity(existingEntity.Id); + cleanups.Add(() => _physicsHosts.Remove(serverGuid)); + cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id)); + cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id)); // Dead-reckon state is keyed by SERVER guid (not local id) so we // clear using the same guid the next spawn/update would use. - _remoteDeadReckon.Remove(serverGuid); - _remoteLastMove.Remove(serverGuid); - if (_selection.SelectedObjectId == serverGuid) + cleanups.Add(() => _remoteDeadReckon.Remove(serverGuid)); + cleanups.Add(() => _remoteLastMove.Remove(serverGuid)); + cleanups.Add(() => { - _selection.Clear( - AcDream.Core.Selection.SelectionChangeSource.System, - AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); - } + if (_selection.SelectedObjectId == serverGuid) + { + _selection.Clear( + AcDream.Core.Selection.SelectionChangeSource.System, + AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); + } + }); - _translucencyFades.ClearEntity(existingEntity.Id); // #188 - LeaveWorldLiveEntityRuntimeComponents(record); + cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188 + cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record)); // Logical teardown ends the retained shadow payload too. The weaker // pickup/parent path stops after Suspend so re-entry can restore it. - _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); - _liveEntityLights?.Forget(existingEntity.Id); + cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id)); + cleanups.Add(() => _liveEntityLights?.Forget(existingEntity.Id)); + AcDream.App.World.LiveEntityTeardown.Run(cleanups); } /// @@ -5075,7 +5207,8 @@ public sealed class GameWindow : IDisposable // MoveToPosition at the wire origin (§2f). if (update.MotionState.MovementType == 6 && 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.ObjectId = tgtGuid; @@ -5115,7 +5248,8 @@ public sealed class GameWindow : IDisposable var ms = new AcDream.Core.Physics.MovementStruct { Params = mp }; if (update.MotionState.MovementType == 8 && 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.ObjectId = turnTgt; @@ -5761,15 +5895,25 @@ public sealed class GameWindow : IDisposable /// 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( parsed.Guid, - (AcDream.Core.Physics.PhysicsStateFlags)parsed.PhysicsState, + record.FinalPhysicsState, _physicsScriptGameTime, _liveCenterX, _liveCenterY); - _liveEntityLights?.OnStateChanged(parsed.Guid); + if (parsed.Guid == _playerServerGuid) + _playerController?.ApplyPhysicsState(record.FinalPhysicsState); if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; @@ -5782,11 +5926,9 @@ public sealed class GameWindow : IDisposable // ACE flipped the ETHEREAL bit. uint registryKey = entity.Id; - _physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) 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( @@ -5995,6 +6137,14 @@ public sealed class GameWindow : IDisposable : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); 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 // timestamp gate above already rejected stale corrections; returning // here prevents the generic remote locomotion path from allocating a @@ -6013,6 +6163,13 @@ public sealed class GameWindow : IDisposable _liveCenterY) == true) return; + if (remotePlacementRequired) + { + _remoteTeleportController!.BeginPlacement( + update.Guid, + acceptedSpawn.InstanceSequence); + } + // Capture the pre-update render position for the soft-snap residual // calculation below. Assign entity.Position to the server truth up // 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; } + // 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 // player remotes. Mirrors CPhysicsObj::MoveOrTeleport // (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 // cell adopted above (:5735). The per-tick loop keeps it current // between UPs (movement-gated). - if (rmState.CellId != 0) + if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid)) _remotePhysicsUpdater.SyncRemoteShadowToBody( entity.Id, rmState, _liveCenterX, _liveCenterY); 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 // the per-tick loop skips). The per-tick loop keeps it current between // UPs, movement-gated. rmState.CellId is the server cell adopted above. - if (rmState.CellId != 0) + if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid)) _remotePhysicsUpdater.SyncRemoteShadowToBody( 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. _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 // the physics-resolved location each frame. @@ -8787,7 +8991,8 @@ public sealed class GameWindow : IDisposable pe.ParentCellId = result.CellId; pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( 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 // so it doesn't get frustum-culled when the player walks away from @@ -8858,7 +9063,7 @@ public sealed class GameWindow : IDisposable trackedTargetPoint: trackedCombatTarget); // 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. // 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 // retains retail's animation-hook versus object-hook phase barrier. _scriptRunner?.PublishTime(_physicsScriptGameTime); + if (_liveEntities is { } liveEntities) + { + _remotePhysicsUpdater.TickHiddenEntities( + liveEntities, + _playerServerGuid, + (float)deltaSeconds, + entity => _effectPoses.UpdateRoot(entity)); + } _projectileController?.Tick( _physicsScriptGameTime, _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))); + } + /// /// Phase 6.4: advance every animated entity's frame counter by /// * Framerate, wrapping around the cycle's @@ -10433,6 +10662,21 @@ public sealed class GameWindow : IDisposable && _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false) 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. // The server broadcasts UpdatePosition at ~5-10Hz for distant // entities; without integration, remote chars jitter-hop between @@ -13570,6 +13814,8 @@ public sealed class GameWindow : IDisposable } _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 // wiring shape as EnsureRemoteMotionBindings, with three @@ -14192,6 +14438,9 @@ public sealed class GameWindow : IDisposable { _liveEntityLights?.Dispose(); _liveEntityLights = null; + _liveEntityPresentation?.Dispose(); + _remoteTeleportController?.Dispose(); + _remoteTeleportController = null; _entityEffects?.ClearNetworkState(); _animationHookFrames?.Clear(); _effectPoses.Clear(); diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs index 00219dae..e4ca0e17 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -103,6 +103,19 @@ public sealed class EntityEffectController : IAnimationHookSink /// original mixed F754/F755 order. /// public bool OnLiveEntityReady(uint serverGuid) + { + if (!PrepareLiveEntityOwner(serverGuid)) + return false; + ReplayPendingForLiveEntity(serverGuid); + return true; + } + + /// + /// 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. + /// + public bool PrepareLiveEntityOwner(uint serverGuid) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.WorldEntity is not { } entity @@ -116,7 +129,15 @@ public sealed class EntityEffectController : IAnimationHookSink _profilesByLocalId[entity.Id] = profile; _runner.SetOwnerAnchor(entity.Id, entity.Position); _ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid); - TryReplayPending(serverGuid, entity.Id); + return true; + } + + /// Replays the mixed F754/F755 FIFO after full object construction. + public bool ReplayPendingForLiveEntity(uint serverGuid) + { + if (!TryGetReadyLocalId(serverGuid, out uint localId)) + return false; + TryReplayPending(serverGuid, localId); return true; } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 45fd9bf9..563b81ea 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -701,6 +701,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable foreach (var animatedId in animatedEntityIds) { if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue; + if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue; // Phase A8: EntitySet partition for indoor/outdoor split passes. if (!EntityMatchesSet(entity, set)) continue; if (entity.MeshRefs.Count == 0) continue; @@ -722,6 +723,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable foreach (var entity in entry.Entities) { + if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue; // Phase A8: EntitySet partition for indoor/outdoor split passes. if (!EntityMatchesSet(entity, set)) continue; if (entity.MeshRefs.Count == 0) continue; diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 346b6441..b0601002 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -88,6 +88,8 @@ public sealed class GpuWorldState // list, so rebuilding it replaces the reference atomically. private IReadOnlyList _flatEntities = System.Array.Empty(); private readonly HashSet _visibleLiveGuids = new(); + private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new(); + private bool _dispatchingVisibilityTransitions; public IReadOnlyList Entities => _flatEntities; public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys; @@ -282,6 +284,10 @@ public sealed class GpuWorldState // 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 // 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); PlaceLiveEntityProjection(canonical, entity); } @@ -307,7 +313,6 @@ public sealed class GpuWorldState if (j != i) newList.Add(entities[j]); _loaded[kvp.Key] = new LoadedLandblock( kvp.Value.LandblockId, kvp.Value.Heightmap, newList); - RebuildFlatView(); return; } } @@ -510,6 +515,7 @@ public sealed class GpuWorldState bucket.Add(entity); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)"); + RebuildFlatView(); } /// @@ -624,21 +630,48 @@ public sealed class GpuWorldState _flatEntities .Where(entity => entity.ServerGuid != 0) .Select(entity => entity.ServerGuid)); - foreach (uint guid in _visibleLiveGuids) - { - if (!nowVisible.Contains(guid)) - LiveProjectionVisibilityChanged?.Invoke(guid, false); - } - foreach (uint guid in nowVisible) - { - if (!_visibleLiveGuids.Contains(guid)) - LiveProjectionVisibilityChanged?.Invoke(guid, true); - } + uint[] becameHidden = _visibleLiveGuids + .Where(guid => !nowVisible.Contains(guid)) + .ToArray(); + uint[] becameVisible = nowVisible + .Where(guid => !_visibleLiveGuids.Contains(guid)) + .ToArray(); + + // Commit the new spatial truth before notifying observers. A + // 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.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(); } + 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 // view, for EntityVanishProbe transition logging. Strip with the probe. private readonly HashSet _persistentInFlatProbe = new(); diff --git a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs index 1b262c35..839ab8f4 100644 --- a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs +++ b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs @@ -20,6 +20,7 @@ public sealed class RadarSnapshotProvider private readonly ClientObjectTable _objects; private readonly IReadOnlyDictionary _worldEntities; + private readonly IReadOnlyDictionary _playerEntities; private readonly IReadOnlyDictionary _spawns; private readonly Func _playerGuid; private readonly Func _playerYawRadians; @@ -39,10 +40,12 @@ public sealed class RadarSnapshotProvider Func selectedGuid, Func coordinatesOnRadar, Func uiLocked, - Func? relationshipFor = null) + Func? relationshipFor = null, + IReadOnlyDictionary? playerEntities = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities)); + _playerEntities = playerEntities ?? worldEntities; _spawns = spawns ?? throw new ArgumentNullException(nameof(spawns)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians)); @@ -57,7 +60,7 @@ public sealed class RadarSnapshotProvider { bool uiLocked = _uiLocked(); 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 }; float heading = MoveToMath.HeadingFromYaw(_playerYawRadians()); diff --git a/src/AcDream.App/World/InboundPhysicsStateController.cs b/src/AcDream.App/World/InboundPhysicsStateController.cs index 7102b150..58ebde3c 100644 --- a/src/AcDream.App/World/InboundPhysicsStateController.cs +++ b/src/AcDream.App/World/InboundPhysicsStateController.cs @@ -319,7 +319,10 @@ public sealed class InboundPhysicsStateController update.TeleportSequence, update.ForcePositionSequence, isLocalPlayer); - timestamps = Current(gate); + timestamps = Current( + gate, + teleportAdvanced: disposition is PositionTimestampDisposition.Apply + && advancesTeleport); if (disposition is PositionTimestampDisposition.Rejected) { accepted = MirrorGateTimestamps(old, gate); @@ -438,11 +441,14 @@ public sealed class InboundPhysicsStateController 0, spawn.InstanceSequence); - private static AcceptedPhysicsTimestamps Current(PhysicsTimestampGate gate) => new( + private static AcceptedPhysicsTimestamps Current( + PhysicsTimestampGate gate, + bool teleportAdvanced = false) => new( gate.InstanceTimestamp, gate.ServerControlledMoveTimestamp, gate.TeleportTimestamp, - gate.ForcePositionTimestamp); + gate.ForcePositionTimestamp, + teleportAdvanced); private static WorldSession.EntitySpawn MirrorGateTimestamps( WorldSession.EntitySpawn spawn, @@ -622,7 +628,9 @@ public readonly record struct AcceptedPhysicsTimestamps( ushort Instance, ushort ServerControlledMove, ushort Teleport, - ushort ForcePosition); + ushort ForcePosition, + bool TeleportAdvanced = false, + bool TeleportHookRequired = false); public readonly record struct CreateParentUpdate( uint ChildGuid, diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs new file mode 100644 index 00000000..7737cd37 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityPresentationController.cs @@ -0,0 +1,282 @@ +using AcDream.App.Physics; +using AcDream.Core.Physics; + +namespace AcDream.App.World; + +/// +/// Applies the presentation/collision side effects of accepted retail +/// PhysicsState transitions after the canonical live owner is ready. +/// Logical ownership stays in ; Hidden never +/// unregisters scripts, particles, lights, meshes, or the live record. +/// +/// +/// Ports CPhysicsObj::set_state (0x00514DD0), +/// set_nodraw (0x0050FCA0), and set_hidden +/// (0x00514C60). Typed script ids are retail +/// PS_UnHide=0x75 and PS_Hidden=0x76 from acclient.h. +/// +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 _playTyped; + private readonly Action _setDirectChildrenNoDraw; + private readonly Action _clearInvalidTarget; + private readonly Func<(int X, int Y)> _liveCenter; + private readonly Action? _onShadowRestored; + private readonly Dictionary _readyGenerationByGuid = new(); + private readonly Dictionary _suspendedShadowGenerationByGuid = new(); + private readonly Dictionary _activePlacementGenerationByGuid = new(); + private bool _disposed; + + public LiveEntityPresentationController( + LiveEntityRuntime liveEntities, + ShadowObjectRegistry shadows, + Func playTyped, + Action? setDirectChildrenNoDraw = null, + Action? clearInvalidTarget = null, + Func<(int X, int Y)>? liveCenter = null, + Action? 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; + } + + /// + /// Opens the state-side-effect barrier after render/effect owners have + /// registered, then drains constructor and pre-materialization transitions + /// exactly once in accepted order. + /// + 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; + } + + /// Drains a newly accepted SetState when this incarnation is ready. + 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; + } + + /// + /// 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. + /// + public bool DeferShadowRestore(uint serverGuid, ushort generation) + => CompleteAuthoritativePlacement(serverGuid, generation, deferShadowRestore: true); + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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; +} diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index e2a32d13..1830e541 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -3,6 +3,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; +using System.Numerics; namespace AcDream.App.World; @@ -32,6 +33,24 @@ public interface ILiveEntityCanonicalCellConsumer void BindCanonicalCell(Func read, Action write); } +/// +/// 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. +/// +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(); +} + /// Projectile physics state owned by a live object. public interface ILiveEntityProjectileRuntime { @@ -83,11 +102,15 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif /// public sealed class LiveEntityRecord { + private readonly Queue _pendingStateTransitions = new(); + internal LiveEntityRecord(WorldSession.EntitySpawn snapshot) { ServerGuid = snapshot.Guid; Snapshot = snapshot; RefreshDerivedState(); + FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState; + ApplyRawPhysicsState(RawPhysicsState); } public uint ServerGuid { get; } @@ -102,6 +125,7 @@ public sealed class LiveEntityRecord public PhysicsBody? PhysicsBody { get; internal set; } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } + internal bool RequiresRemotePlacementRuntime { get; set; } public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; } public bool ResourcesRegistered { get; internal set; } @@ -110,6 +134,31 @@ public sealed class LiveEntityRecord public bool WorldSpawnPublished { 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; + } + /// /// Cell used when a streamed landblock asks live objects to restore their /// spatial projection. Once materialized, the canonical runtime cell wins; @@ -129,7 +178,6 @@ public sealed class LiveEntityRecord RawPhysicsState = Snapshot.Physics?.RawState ?? Snapshot.PhysicsState ?? 0u; - FinalPhysicsState = (PhysicsStateFlags)RawPhysicsState; } } @@ -205,8 +253,9 @@ public sealed class LiveEntityRuntime /// /// Currently visible top-level world projections. Attached children and - /// pending projections are excluded from radar, picking, status, and target - /// candidate consumers. + /// pending or Hidden projections are excluded from radar, picking, status, + /// and target candidate consumers. NoDraw alone suppresses the mesh but is + /// not a retail cell-visibility transition. /// public IReadOnlyDictionary WorldEntities => _visibleWorldEntitiesByGuid; @@ -330,6 +379,15 @@ public sealed class LiveEntityRuntime } 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); return record.WorldEntity; } @@ -362,10 +420,7 @@ public sealed class LiveEntityRuntime _materializedWorldEntitiesByGuid[serverGuid] = entity; else _materializedWorldEntitiesByGuid.Remove(serverGuid); - if (visible && record.ProjectionKind is LiveEntityProjectionKind.World) - _visibleWorldEntitiesByGuid[serverGuid] = entity; - else - _visibleWorldEntitiesByGuid.Remove(serverGuid); + RefreshPresentation(record); if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu) record.FullCellId = spatialCellOrLandblockId; record.CanonicalLandblockId = spatialCellOrLandblockId == 0 @@ -387,13 +442,38 @@ public sealed class LiveEntityRuntime return false; _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); _visibleWorldEntitiesByGuid.Remove(serverGuid); record.IsSpatiallyProjected = false; record.IsSpatiallyVisible = false; + if (spatialEdgeWasDeferred) + ProjectionVisibilityChanged?.Invoke(record, false); return true; } + /// + /// Withdraws a still-live projection whose authoritative rollback frame + /// is outside every world cell. Logical owners remain registered. + /// + 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( DeleteObject.Parsed delete, bool isLocalPlayer, @@ -449,6 +529,16 @@ public sealed class LiveEntityRuntime return false; } + /// + /// 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. + /// + public bool TryGetInteractionEligibleEntity( + uint serverGuid, + out WorldEntity entity) => + _visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!); + public bool ContainsWorldEntity(uint serverGuid) => _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) && record.WorldEntity is not null; @@ -503,14 +593,24 @@ public sealed class LiveEntityRuntime ArgumentNullException.ThrowIfNull(runtime); if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); - if (record.ProjectileRuntime is { } projectile - && !ReferenceEquals(projectile.Body, runtime.Body)) + PhysicsBody candidateBody = runtime.Body; + if (record.PhysicsBody is { } canonicalBody + && !ReferenceEquals(canonicalBody, candidateBody)) { 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.PhysicsBody = runtime.Body; + record.PhysicsBody = candidateBody; + candidateBody.State = record.FinalPhysicsState; if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) { // Capture the incarnation, not only its GUID. A callback retained @@ -548,8 +648,6 @@ public sealed class LiveEntityRuntime return false; _remoteMotionByGuid.Remove(serverGuid); record.RemoteMotionRuntime = null; - if (record.ProjectileRuntime is null) - record.PhysicsBody = null; return true; } @@ -562,21 +660,17 @@ public sealed class LiveEntityRuntime throw new InvalidOperationException( $"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized."); } - if (record.RemoteMotionRuntime is { } remote - && !ReferenceEquals(remote.Body, runtime.Body)) + PhysicsBody candidateBody = runtime.Body; + if (record.PhysicsBody is { } canonicalBody + && !ReferenceEquals(canonicalBody, candidateBody)) { throw new InvalidOperationException( - $"Live entity 0x{serverGuid:X8} already owns a different remote-motion physics body."); - } - 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."); + $"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation."); } record.ProjectileRuntime = runtime; - record.PhysicsBody = runtime.Body; + record.PhysicsBody = candidateBody; + candidateBody.State = record.FinalPhysicsState; } public bool TryGetProjectileRuntime( @@ -601,8 +695,6 @@ public sealed class LiveEntityRuntime return false; record.ProjectileRuntime = null; - if (record.RemoteMotionRuntime is null) - record.PhysicsBody = null; return true; } @@ -694,12 +786,40 @@ public sealed class LiveEntityRuntime } 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); - 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; } + /// + /// Retail parent Hidden directly toggles each attached child's NoDraw bit + /// without consuming the child's STATE_TS or replacing its raw wire state. + /// + 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( WorldSession.EntityPositionUpdate update, bool isLocalPlayer, @@ -709,6 +829,12 @@ public sealed class LiveEntityRuntime out WorldSession.EntitySpawn accepted, out AcceptedPhysicsTimestamps timestamps) { + bool wasCellLess = _recordsByGuid.TryGetValue( + update.Guid, + out LiveEntityRecord? beforePosition) + && (beforePosition.FullCellId == 0 + || !beforePosition.IsSpatiallyProjected + || !beforePosition.IsSpatiallyVisible); bool known = _inbound.TryApplyPosition( update, isLocalPlayer, @@ -720,6 +846,13 @@ public sealed class LiveEntityRuntime if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) { 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); if (acceptedPosition) { @@ -734,8 +867,11 @@ public sealed class LiveEntityRuntime /// /// Retail CPhysicsObj::update_object (0x00515D40) runs root - /// movement/animation only while the object has visible world-cell - /// membership and is not Frozen. A pickup or parent transition keeps + /// object tick only while the object has visible world-cell membership and + /// 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 /// projection until a fresh Position re-enters it. /// @@ -746,6 +882,10 @@ public sealed class LiveEntityRuntime && record.FullCellId != 0 && (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0; + public bool IsHidden(uint serverGuid) => + _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; + /// /// Claims the one logical top-level spawn notification for this object /// incarnation. Spatial re-entry must restore presentation without replaying @@ -832,15 +972,31 @@ public sealed class LiveEntityRuntime || record.WorldEntity is not { } entity) return; + bool wasVisible = record.IsSpatiallyVisible; record.IsSpatiallyVisible = visible; - if (visible && record.ProjectionKind is LiveEntityProjectionKind.World) - _visibleWorldEntitiesByGuid[serverGuid] = entity; - else - _visibleWorldEntitiesByGuid.Remove(serverGuid); - if (_rebucketingGuid != serverGuid) + RefreshPresentation(record); + if (_rebucketingGuid != serverGuid && wasVisible != 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) { List? failures = null; @@ -873,6 +1029,7 @@ public sealed class LiveEntityRuntime _remoteMotionByGuid.Remove(record.ServerGuid); record.AnimationRuntime = null; record.RemoteMotionRuntime = null; + record.RequiresRemotePlacementRuntime = false; record.PhysicsBody = null; record.ProjectileRuntime = null; record.EffectProfile = null; diff --git a/src/AcDream.App/World/LiveEntityTeardown.cs b/src/AcDream.App/World/LiveEntityTeardown.cs new file mode 100644 index 00000000..3652a25f --- /dev/null +++ b/src/AcDream.App/World/LiveEntityTeardown.cs @@ -0,0 +1,33 @@ +namespace AcDream.App.World; + +/// +/// 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. +/// +internal static class LiveEntityTeardown +{ + internal static void Run(IEnumerable cleanups) + { + ArgumentNullException.ThrowIfNull(cleanups); + List? 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); + } +} diff --git a/src/AcDream.Core/Physics/Motion/TargetManager.cs b/src/AcDream.Core/Physics/Motion/TargetManager.cs index 1567f979..7ca908a9 100644 --- a/src/AcDream.Core/Physics/Motion/TargetManager.cs +++ b/src/AcDream.Core/Physics/Motion/TargetManager.cs @@ -297,4 +297,22 @@ public sealed class TargetManager foreach (var voyeur in _voyeurTable.Values.ToList()) SendVoyeurUpdate(voyeur, _host.Position, status); } + + /// + /// 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 () is untouched. + /// + public void NotifyVoyeurOfEventAndClear(TargetStatus status) + { + if (_voyeurTable == null) + return; + + foreach (var voyeur in _voyeurTable.Values.ToList()) + SendVoyeurUpdate(voyeur, _host.Position, status); + _voyeurTable.Clear(); + } } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 7efef4e1..088813c2 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1388,7 +1388,11 @@ public sealed class PhysicsEngine bool ok = transition.FindPlacementPos(this); var sp = transition.SpherePath; 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); return new ResolveResult( @@ -1397,6 +1401,12 @@ public sealed class PhysicsEngine onGround, ci.CollisionNormalValid, ci.CollisionNormal, - ok); + ok, + Orientation: sp.CurOrientation, + InContact: inContact, + OnWalkable: onWalkable, + ContactPlane: ci.ContactPlane, + ContactPlaneCellId: ci.ContactPlaneCellId, + ContactPlaneIsWater: ci.ContactPlaneIsWater); } } diff --git a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs index 4d9ef950..cdce28ea 100644 --- a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs +++ b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs @@ -45,6 +45,70 @@ public static class PhysicsObjUpdate body.calc_acceleration(); } + /// + /// Commits the contact/walkable/collision-response tail of retail + /// CPhysicsObj::SetPositionInternal (0x00515330) 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. + /// + /// + /// Retail writes Contact and recalculates acceleration, calls + /// set_on_walkable (which invokes HitGround/LeaveGround), then calls + /// handle_all_collisions at 0x005154FE. Collision response + /// must therefore observe any velocity change made by the movement + /// callback; moving that callback after reflection changes the result. + /// + 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); + } + /// /// retail handle_all_collisions (0x00514780). Reflects or zeros the body's /// (retail m_velocityVector) based on diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs index da3f2e83..177672b0 100644 --- a/src/AcDream.Core/Physics/ResolveResult.cs +++ b/src/AcDream.Core/Physics/ResolveResult.cs @@ -51,4 +51,10 @@ public readonly record struct ResolveResult( /// Exact retail walkability result after testing the contact-plane normal /// against . /// - bool OnWalkable = false); + bool OnWalkable = false, + /// The accepted SetPosition contact plane, when present. + Plane ContactPlane = default, + /// Full cell that owns . + uint ContactPlaneCellId = 0, + /// Whether the accepted contact plane is water. + bool ContactPlaneIsWater = false); diff --git a/src/AcDream.Core/Physics/RetailPhysicsStateTransition.cs b/src/AcDream.Core/Physics/RetailPhysicsStateTransition.cs new file mode 100644 index 00000000..c5de51d0 --- /dev/null +++ b/src/AcDream.Core/Physics/RetailPhysicsStateTransition.cs @@ -0,0 +1,89 @@ +namespace AcDream.Core.Physics; + +/// +/// Result of retail CPhysicsObj::set_state (0x00514DD0) and +/// the nested set_hidden call (0x00514C60). +/// +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, +} + +/// +/// 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. +/// +public static class RetailPhysicsStateTransitions +{ + /// + /// Retail CPhysicsObj constructor state at 0x00512508. + /// This deliberately does not contain Hidden: a normal visible + /// CreateObject must not manufacture an UnHide script. + /// + 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); + } +} diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index 33b8159a..d70aa3b3 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -30,6 +30,22 @@ public sealed class WorldEntity /// public required IReadOnlyList MeshRefs { get; set; } + /// + /// 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. + /// + public bool IsDrawVisible { get; set; } = true; + + /// + /// 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. + /// + public bool IsAncestorDrawVisible { get; set; } = true; + /// /// Stable Setup-part-indexed poses used by attachment and effect hooks. /// Unlike , this array never compacts around a DAT diff --git a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs new file mode 100644 index 00000000..354830da --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs @@ -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(); + 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(), + }; + var poses = new EntityEffectPoseRegistry(); + poses.Publish(entity, Array.Empty()); + + 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); + } +} diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index c90c4b4b..0be47c48 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -52,6 +52,96 @@ public sealed class ProjectileControllerTests 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] public void PendingDestinationAndLaterHydration_RetainIdentityAndProjectileOwner() { diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs new file mode 100644 index 00000000..40c8dfd5 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -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(), + }; + + var updater = new RemotePhysicsUpdater( + new PhysicsEngine(), + (_, _) => (0.48f, 1.835f), + (_, _, _, _) => { }); + var poses = new EntityEffectPoseRegistry(); + poses.Publish(entity, Array.Empty()); + 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(), + }; + + 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(), + }; + + 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(), + Array.Empty(), + 0f, + 0f); + engine.AddLandblock( + 0xAAB4FFFFu, + FlatTerrain(), + Array.Empty(), + Array.Empty(), + 192f, + 0f); + return engine; + } +} diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs new file mode 100644 index 00000000..c2c3271e --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs @@ -0,0 +1,1288 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Physics; + +public sealed class RemoteTeleportControllerTests +{ + private sealed class Resources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) { } + } + + private sealed class BodyOnlyRemote(PhysicsBody body) : ILiveEntityRemoteMotionRuntime + { + public PhysicsBody Body { get; } = body; + } + + private sealed class MutablePlacementRemote(PhysicsBody body) : ILiveEntityRemotePlacementRuntime + { + private uint _cellId; + private Func? _readCell; + private Action? _writeCell; + + public PhysicsBody Body { get; set; } = body; + public uint CellId + { + get => _readCell?.Invoke() ?? _cellId; + set + { + _cellId = value; + _writeCell?.Invoke(value); + } + } + public bool Airborne { get; set; } + public Vector3 LastServerPosition { get; set; } + public double LastServerPositionTime { get; set; } + public Vector3 LastShadowSyncPosition { get; set; } + public void BindCanonicalCell(Func read, Action write) + { + _readCell = read; + _writeCell = write; + } + public void HitGround() { } + public void LeaveGround() { } + } + + private sealed class AlternatingBodyRemote( + PhysicsBody first, + PhysicsBody later) : ILiveEntityRemotePlacementRuntime + { + private int _reads; + public PhysicsBody Body => _reads++ == 0 ? first : later; + public uint CellId { get; set; } + public bool Airborne { get; set; } + public Vector3 LastServerPosition { get; set; } + public double LastServerPositionTime { get; set; } + public Vector3 LastShadowSyncPosition { get; set; } + public void BindCanonicalCell(Func read, Action write) { } + public void HitGround() { } + public void LeaveGround() { } + } + + private sealed class AlternatingProjectileRuntime( + PhysicsBody first, + PhysicsBody later) : ILiveEntityProjectileRuntime + { + private int _reads; + public PhysicsBody Body => _reads++ == 0 ? first : later; + } + + [Fact] + public void PendingDestination_ParksAuthoritativeFrameThenResolvesContactOnLoad() + { + const uint guid = 0x70000001u; + const uint sourceCell = 0xA9B40039u; + const uint destinationCell = 0xAAB40001u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + var live = new LiveEntityRuntime(spatial, new Resources()); + live.RegisterLiveEntity(Spawn(guid, sourceCell)); + WorldEntity entity = live.MaterializeLiveEntity( + guid, + sourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(191f, 10f, 50f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + })!; + var remote = new GameWindow.RemoteMotion(); + remote.Body.SnapToCell( + sourceCell, + entity.Position, + entity.Position); + remote.Body.TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable; + live.SetRemoteMotionRuntime(guid, remote); + var engine = BuildEngine(includeDestination: false); + engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + radius: 0.48f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0xA9B4FFFFu, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 1.835f, + seedCellId: sourceCell, + isStatic: false); + Assert.True(engine.ShadowObjects.Suspend(entity.Id)); + int shadowSyncs = 0; + using var controller = new RemoteTeleportController( + engine, + live, + (_, _) => (0.48f, 1.835f), + CellLocal, + (resolvedEntity, resolvedRemote, resolvedCell) => + { + shadowSyncs++; + engine.ShadowObjects.Register( + resolvedEntity.Id, + resolvedEntity.SourceGfxObjOrSetupId, + resolvedRemote.Position, + resolvedRemote.Orientation, + radius: 0.48f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0xAAB4FFFFu, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 1.835f, + seedCellId: resolvedCell, + isStatic: false); + }, + (_, _, _) => { }, + (_, _) => { }); + + Assert.True(live.RebucketLiveEntity(guid, destinationCell)); + var result = controller.TryApply( + remote, + entity, + new Vector3(193f, 10f, 50f), + destinationCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 5.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 1); + + Assert.True(result.Applied); + Assert.False(result.ContactResolved); + Assert.Equal(destinationCell, remote.CellId); + Assert.Equal(new Vector3(193f, 10f, 50f), entity.Position); + Assert.False(remote.Body.InContact); + Assert.Equal(0, engine.ShadowObjects.TotalRegistered); + Assert.True(live.TryGetRecord(guid, out LiveEntityRecord pending)); + Assert.False(pending.IsSpatiallyVisible); + + Assert.True(live.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition( + destinationCell, 2f, 10f, 50f, 1f, 0f, 0f, 0f), + null, + null, + true, + 1, + 2, + 0, + 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + var latest = controller.TryApply( + remote, + entity, + new Vector3(194f, 10f, 50f), + destinationCell, + new Vector3(2f, 10f, 50f), + Quaternion.Identity, + gameTime: 6.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 2); + Assert.True(latest.Applied); + Assert.False(latest.ContactResolved); + + AddDestination(engine); + spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); + + Assert.True(pending.IsSpatiallyVisible); + Assert.Equal(destinationCell, pending.FullCellId); + Assert.True(remote.Body.InContact); + Assert.True(remote.Body.OnWalkable); + Assert.False(remote.Airborne); + Assert.Equal(194f, entity.Position.X, 3); + Assert.Equal(remote.Body.Position, entity.Position); + Assert.Equal(1, shadowSyncs); + Assert.Equal(1, engine.ShadowObjects.TotalRegistered); + ShadowEntry restored = Assert.Single( + engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(entity.Position, restored.Position); + } + + [Fact] + public void LoadedPlacementFailure_RestoresSuspendedShadowAtRetainedPose() + { + const uint guid = 0x70000002u; + const uint sourceCell = 0xA9B40039u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + var live = new LiveEntityRuntime(spatial, new Resources()); + live.RegisterLiveEntity(Spawn(guid, sourceCell)); + WorldEntity entity = live.MaterializeLiveEntity( + guid, + sourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(10f, 10f, 50f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + })!; + var remote = new GameWindow.RemoteMotion(); + remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); + live.SetRemoteMotionRuntime(guid, remote); + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + 0.48f, + 0f, + 0f, + 0xA9B4FFFFu, + ShadowCollisionType.Cylinder, + 1.835f, + seedCellId: sourceCell, + isStatic: false); + Assert.True(engine.ShadowObjects.Suspend(entity.Id)); + int shadowSyncs = 0; + using var controller = new RemoteTeleportController( + engine, + live, + (_, _) => (0.48f, 1.835f), + CellLocal, + (resolvedEntity, resolvedRemote, resolvedCell) => + { + shadowSyncs++; + engine.ShadowObjects.Register( + resolvedEntity.Id, + resolvedEntity.SourceGfxObjOrSetupId, + resolvedRemote.Position, + resolvedRemote.Orientation, + 0.48f, + 0f, + 0f, + 0xA9B4FFFFu, + ShadowCollisionType.Cylinder, + 1.835f, + seedCellId: resolvedCell, + isStatic: false); + }, + (_, _, _) => { }, + (_, _) => { }, + resolvePlacement: (position, cell, _, _, _, _) => + new ResolveResult( + position, + cell, + IsOnGround: false, + Ok: false)); + + RemoteTeleportController.Result result = controller.TryApply( + remote, + entity, + new Vector3(20f, 20f, 50f), + requestedCellId: 0u, + requestedCellLocalPosition: Vector3.Zero, + requestedOrientation: Quaternion.Identity, + gameTime: 2.0, + destinationProjectionVisible: true, + generation: 1, + positionSequence: 1); + + Assert.False(result.Applied); + Assert.Equal(1, shadowSyncs); + Assert.Equal(1, engine.ShadowObjects.TotalRegistered); + Assert.Equal(new Vector3(10f, 10f, 50f), entity.Position); + } + + [Fact] + public void DeferredPlacementFailure_RollsBackProjectionBodyAndShadow() + { + const uint guid = 0x70000003u; + const uint sourceCell = 0xA9B40039u; + const uint destinationCell = 0xAAB40001u; + Vector3 sourcePosition = new(191f, 10f, 50f); + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + var live = new LiveEntityRuntime(spatial, new Resources()); + live.RegisterLiveEntity(Spawn(guid, sourceCell)); + WorldEntity entity = live.MaterializeLiveEntity( + guid, + sourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = sourcePosition, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + })!; + var remote = new GameWindow.RemoteMotion(); + remote.Body.SnapToCell(sourceCell, sourcePosition, sourcePosition); + remote.Body.TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable; + remote.CellId = sourceCell; + live.SetRemoteMotionRuntime(guid, remote); + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + sourcePosition, + Quaternion.Identity, + 0.48f, + 0f, + 0f, + 0xA9B4FFFFu, + ShadowCollisionType.Cylinder, + 1.835f, + seedCellId: sourceCell, + isStatic: false); + Assert.True(engine.ShadowObjects.Suspend(entity.Id)); + int shadowSyncs = 0; + using var controller = new RemoteTeleportController( + engine, + live, + (_, _) => (0.48f, 1.835f), + CellLocal, + (resolvedEntity, resolvedRemote, resolvedCell) => + { + shadowSyncs++; + engine.ShadowObjects.Register( + resolvedEntity.Id, + resolvedEntity.SourceGfxObjOrSetupId, + resolvedRemote.Position, + resolvedRemote.Orientation, + 0.48f, + 0f, + 0f, + 0xA9B4FFFFu, + ShadowCollisionType.Cylinder, + 1.835f, + seedCellId: resolvedCell, + isStatic: false); + }, + (_, _, _) => { }, + (_, _) => { }, + resolvePlacement: (position, cell, _, _, _, _) => + new ResolveResult( + position, + cell, + IsOnGround: false, + Ok: false)); + + Assert.True(live.RebucketLiveEntity(guid, destinationCell)); + RemoteTeleportController.Result parked = controller.TryApply( + remote, + entity, + new Vector3(193f, 10f, 50f), + destinationCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 5.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 1); + Assert.True(parked.Applied); + Assert.True(controller.HasPending(guid)); + + // Rendering becomes resident without matching physics collision data. + // The visibility edge is the one resolution attempt; failure must + // restore the retained source instead of leaving a visible ghost. + spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); + + Assert.False(controller.HasPending(guid)); + Assert.Equal(sourcePosition, remote.Body.Position); + Assert.Equal(sourceCell, remote.CellId); + Assert.Equal(sourcePosition, entity.Position); + Assert.Equal(sourceCell, entity.ParentCellId); + Assert.True(remote.Body.InContact); + Assert.True(remote.Body.OnWalkable); + Assert.True(live.TryGetRecord(guid, out LiveEntityRecord restored)); + Assert.Equal(sourceCell, restored.FullCellId); + Assert.True(restored.IsSpatiallyVisible); + Assert.Equal(1, shadowSyncs); + Assert.Equal(1, engine.ShadowObjects.TotalRegistered); + } + + [Fact] + public void DeferredFailure_WithUnloadedSource_RestoresShadowWhenSourceReloads() + { + using var fixture = new RollbackFixture(); + Assert.True(fixture.ParkPending().Applied); + fixture.VisibilityEdges.Clear(); + + fixture.Spatial.RemoveLandblock(RollbackFixture.SourceLandblock); + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); + + Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord pendingSource)); + Assert.Equal(RollbackFixture.SourceCell, pendingSource.FullCellId); + Assert.False(pendingSource.IsSpatiallyVisible); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.False(fixture.PresentationVisible); + Assert.Equal(new[] { true, false }, fixture.VisibilityEdges); + + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.SourceLandblock)); + + Assert.True(pendingSource.IsSpatiallyVisible); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(1, fixture.ShadowSyncs); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.True(fixture.PresentationVisible); + Assert.Equal(new[] { true, false, true }, fixture.VisibilityEdges); + } + + [Fact] + public void DeferredFailure_WithCelllessSource_WithdrawsPresentationWithoutShadowRestore() + { + using var fixture = new RollbackFixture(celllessSource: true); + Assert.True(fixture.ParkPending().Applied); + Assert.False(fixture.PresentationVisible); + fixture.VisibilityEdges.Clear(); + + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); + + Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord withdrawn)); + Assert.Equal(0u, withdrawn.FullCellId); + Assert.False(withdrawn.IsSpatiallyProjected); + Assert.False(withdrawn.IsSpatiallyVisible); + Assert.False(fixture.PresentationVisible); + Assert.Equal(0, fixture.ShadowSyncs); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(new[] { true, false }, fixture.VisibilityEdges); + } + + [Fact] + public void DeferredFailure_WithLoadedSource_PublishesNoFalseRebucketPulse() + { + using var fixture = new RollbackFixture(); + Assert.True(fixture.ParkPending().Applied); + fixture.VisibilityEdges.Clear(); + + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); + + Assert.Equal(new[] { true }, fixture.VisibilityEdges); + Assert.True(fixture.PresentationVisible); + Assert.Equal(RollbackFixture.SourcePosition, fixture.Entity.Position); + Assert.Equal(RollbackFixture.SourceCell, fixture.Entity.ParentCellId); + Assert.Equal(1, fixture.ShadowSyncs); + } + + [Fact] + public void NewerLoadedFailure_ReplacesPendingAndRestoresOriginalSource() + { + const uint newerCell = 0xABB40001u; + const uint newerLandblock = 0xABB4FFFFu; + using var fixture = new RollbackFixture(); + fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); + Assert.True(fixture.ParkPending().Applied); + + Assert.True(fixture.Live.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + RollbackFixture.Guid, + new CreateObject.ServerPosition( + newerCell, 1f, 10f, 50f, 1f, 0f, 0f, 0f), + null, + null, + true, + 1, + 2, + 0, + 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + fixture.VisibilityEdges.Clear(); + fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); + Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); + Assert.True(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.Equal(new[] { true }, fixture.VisibilityEdges); + + RemoteTeleportController.Result failed = fixture.Controller.TryApply( + fixture.Remote, + fixture.Entity, + new Vector3(385f, 10f, 50f), + newerCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 6.0, + destinationProjectionVisible: true, + generation: 1, + positionSequence: 2); + + Assert.False(failed.Applied); + Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.Equal(RollbackFixture.SourcePosition, fixture.Remote.Body.Position); + Assert.Equal(RollbackFixture.SourceCell, fixture.Remote.CellId); + Assert.True(fixture.Remote.Body.InContact); + Assert.True(fixture.Remote.Body.OnWalkable); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord restored)); + Assert.Equal(RollbackFixture.SourceCell, restored.FullCellId); + Assert.True(restored.IsSpatiallyVisible); + Assert.Equal(1, fixture.ShadowSyncs); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(new[] { true }, fixture.VisibilityEdges); + } + + [Fact] + public void HiddenThenUnhideWhileRollbackSourcePending_RestoresShadowExactlyOnce() + { + using var fixture = new RollbackFixture(); + Assert.True(fixture.ParkPending().Applied); + fixture.Spatial.RemoveLandblock(RollbackFixture.SourceLandblock); + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); + Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + + Assert.True(fixture.Live.TryApplyState( + new SetState.Parsed( + RollbackFixture.Guid, + (uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions), + 1, + 2), + out _, + out _)); + Assert.True(fixture.Presentation.OnStateAccepted(RollbackFixture.Guid)); + Assert.True(fixture.Live.TryApplyState( + new SetState.Parsed( + RollbackFixture.Guid, + (uint)PhysicsStateFlags.ReportCollisions, + 1, + 3), + out _, + out _)); + Assert.True(fixture.Presentation.OnStateAccepted(RollbackFixture.Guid)); + Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.SourceLandblock)); + + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(1, fixture.ShadowSyncs); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DeferredRollback_NewerLoadedPlacementTransfersShadowUntilResolution( + bool resolutionSucceeds) + { + const uint newerCell = 0xABB40001u; + const uint newerLandblock = 0xABB4FFFFu; + using var fixture = new RollbackFixture(); + CreateUnloadedSourceRollback(fixture); + fixture.ResolveSucceeds = resolutionSucceeds; + fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); + + AcceptNewerPosition(fixture, newerCell); + fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); + fixture.VisibilityEdges.Clear(); + Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); + + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(0, fixture.ShadowSyncs); + RemoteTeleportController.Result result = fixture.Controller.TryApply( + fixture.Remote, + fixture.Entity, + new Vector3(385f, 10f, 50f), + newerCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 7.0, + destinationProjectionVisible: true, + generation: 1, + positionSequence: 2); + + Assert.Equal(resolutionSucceeds, result.Applied); + Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.ShadowSyncs); + Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(!resolutionSucceeds, + fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord record)); + Assert.Equal( + resolutionSucceeds ? newerCell : RollbackFixture.SourceCell, + record.FullCellId); + Assert.Equal(resolutionSucceeds, record.IsSpatiallyVisible); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DeferredRollback_NewerPendingHydrationTransfersShadowUntilResolution( + bool resolutionSucceeds) + { + const uint newerCell = 0xACB40001u; + const uint newerLandblock = 0xACB4FFFFu; + using var fixture = new RollbackFixture(); + CreateUnloadedSourceRollback(fixture); + fixture.ResolveSucceeds = resolutionSucceeds; + + AcceptNewerPosition(fixture, newerCell); + fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); + Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); + Assert.True(fixture.Controller.TryApply( + fixture.Remote, + fixture.Entity, + new Vector3(577f, 10f, 50f), + newerCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 7.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 2).Applied); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(0, fixture.ShadowSyncs); + + fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); + + Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.ShadowSyncs); + Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(!resolutionSucceeds, + fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord record)); + Assert.Equal( + resolutionSucceeds ? newerCell : RollbackFixture.SourceCell, + record.FullCellId); + Assert.Equal(resolutionSucceeds, record.IsSpatiallyVisible); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void HiddenPlacement_UnhideBeforeHydrationWaitsForResolution( + bool resolutionSucceeds) + { + const uint newerCell = 0xACB40001u; + const uint newerLandblock = 0xACB4FFFFu; + using var fixture = new RollbackFixture(); + CreateUnloadedSourceRollback(fixture); + fixture.ResolveSucceeds = resolutionSucceeds; + ApplyHidden(fixture, stateSequence: 2); + + AcceptNewerPosition(fixture, newerCell); + fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); + Assert.True(fixture.Controller.TryApply( + fixture.Remote, + fixture.Entity, + new Vector3(577f, 10f, 50f), + newerCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 7.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 2).Applied); + + ApplyVisible(fixture, stateSequence: 3); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(0, fixture.ShadowSyncs); + + fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); + + Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.ShadowSyncs); + Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(!resolutionSucceeds, + fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void PendingPlacement_HideUnhideOscillationCannotRestoreBeforeResolution( + bool resolutionSucceeds) + { + const uint newerCell = 0xADB40001u; + const uint newerLandblock = 0xADB4FFFFu; + using var fixture = new RollbackFixture(); + CreateUnloadedSourceRollback(fixture); + fixture.ResolveSucceeds = resolutionSucceeds; + + AcceptNewerPosition(fixture, newerCell); + fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); + Assert.True(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); + Assert.True(fixture.Controller.TryApply( + fixture.Remote, + fixture.Entity, + new Vector3(769f, 10f, 50f), + newerCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 7.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 2).Applied); + + ApplyHidden(fixture, stateSequence: 2); + ApplyVisible(fixture, stateSequence: 3); + ApplyHidden(fixture, stateSequence: 4); + ApplyVisible(fixture, stateSequence: 5); + + Assert.True(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(0, fixture.ShadowSyncs); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + + fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); + + Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); + Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.ShadowSyncs); + Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(!resolutionSucceeds, + fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void PendingPlacement_SameBodyRuntimeRebindFinishesStableOwnership( + bool resolutionSucceeds) + { + using var fixture = new RollbackFixture(); + fixture.ResolveSucceeds = resolutionSucceeds; + Assert.True(fixture.ParkPending().Applied); + Assert.True(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.True(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); + + Assert.Throws(() => + fixture.Live.SetRemoteMotionRuntime( + RollbackFixture.Guid, + new GameWindow.RemoteMotion())); + Assert.Throws(() => + fixture.Live.SetRemoteMotionRuntime( + RollbackFixture.Guid, + new BodyOnlyRemote(fixture.Remote.Body))); + + var replacement = new GameWindow.RemoteMotion(fixture.Remote.Body); + fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, replacement); + + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); + + Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(1, fixture.ShadowSyncs); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord stable)); + Assert.Same(replacement, stable.RemoteMotionRuntime); + Assert.Equal( + resolutionSucceeds + ? RollbackFixture.DestinationCell + : RollbackFixture.SourceCell, + stable.FullCellId); + Assert.Equal(stable.FullCellId, replacement.CellId); + } + + [Fact] + public void PendingPlacement_RuntimeClearRollsBackAndCompletesOwnership() + { + using var fixture = new RollbackFixture(); + Assert.True(fixture.ParkPending().Applied); + Assert.True(fixture.Live.ClearRemoteMotionRuntime(RollbackFixture.Guid)); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord retained)); + Assert.Same(fixture.Remote.Body, retained.PhysicsBody); + Assert.Throws(() => + fixture.Live.SetRemoteMotionRuntime( + RollbackFixture.Guid, + new GameWindow.RemoteMotion())); + Assert.Throws(() => + fixture.Live.SetRemoteMotionRuntime( + RollbackFixture.Guid, + new BodyOnlyRemote(fixture.Remote.Body))); + + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); + + Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(1, fixture.ShadowSyncs); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord stable)); + Assert.Equal(RollbackFixture.SourceCell, stable.FullCellId); + Assert.Null(stable.RemoteMotionRuntime); + } + + [Fact] + public void PendingPlacement_MutatedWrapperBodyIsDetachedAndCanonicalBodyRollsBack() + { + using var fixture = new RollbackFixture(); + Assert.True(fixture.ParkPending().Applied); + PhysicsBody canonicalBody = fixture.Remote.Body; + var mutable = new MutablePlacementRemote(canonicalBody); + fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, mutable); + + mutable.Body = new PhysicsBody(); + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); + + Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(1, fixture.ShadowSyncs); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord stable)); + Assert.Same(canonicalBody, stable.PhysicsBody); + Assert.Null(stable.RemoteMotionRuntime); + Assert.Equal(RollbackFixture.SourcePosition, canonicalBody.Position); + Assert.Equal(RollbackFixture.SourcePosition, fixture.Entity.Position); + Assert.Equal(RollbackFixture.SourceCell, stable.FullCellId); + } + + [Fact] + public void RuntimeBinding_SnapshotsDynamicBodyGetterOncePerBoundary() + { + using var fixture = new RollbackFixture(); + PhysicsBody canonicalBody = fixture.Remote.Body; + var otherBody = new PhysicsBody(); + var remote = new AlternatingBodyRemote(canonicalBody, otherBody); + + fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, remote); + Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord record)); + Assert.Same(canonicalBody, record.PhysicsBody); + Assert.Same(remote, record.RemoteMotionRuntime); + + var projectile = new AlternatingProjectileRuntime(canonicalBody, otherBody); + fixture.Live.SetProjectileRuntime(RollbackFixture.Guid, projectile); + + Assert.Same(canonicalBody, record.PhysicsBody); + Assert.Same(projectile, record.ProjectileRuntime); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void HiddenPlacement_HydrationHandsRestoreBackToUnhide( + bool resolutionSucceeds) + { + const uint newerCell = 0xACB40001u; + const uint newerLandblock = 0xACB4FFFFu; + using var fixture = new RollbackFixture(); + CreateUnloadedSourceRollback(fixture); + fixture.ResolveSucceeds = resolutionSucceeds; + ApplyHidden(fixture, stateSequence: 2); + + AcceptNewerPosition(fixture, newerCell); + fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); + Assert.True(fixture.Controller.TryApply( + fixture.Remote, + fixture.Entity, + new Vector3(577f, 10f, 50f), + newerCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 7.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 2).Applied); + + fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); + + Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(0, fixture.ShadowSyncs); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + + ApplyVisible(fixture, stateSequence: 3); + + if (!resolutionSucceeds) + { + Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(0, fixture.ShadowSyncs); + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.SourceLandblock)); + } + + Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(1, fixture.ShadowSyncs); + Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); + } + + [Fact] + public void TeardownCallbackFailure_StillForgetsPendingBeforeGuidReuse() + { + const uint guid = 0x70000004u; + const uint sourceCell = 0xA9B40039u; + const uint destinationCell = 0xAAB40001u; + RemoteTeleportController? controller = null; + bool cleanupAfterFailureRan = false; + var resources = new DelegateLiveEntityResourceLifecycle( + _ => { }, + _ => LiveEntityTeardown.Run( + [ + () => throw new InvalidOperationException("effect teardown failed"), + () => controller!.Forget(guid), + () => cleanupAfterFailureRan = true, + ])); + var spatial = new GpuWorldState(); + spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + var live = new LiveEntityRuntime(spatial, resources); + live.RegisterLiveEntity(Spawn(guid, sourceCell)); + WorldEntity entity = live.MaterializeLiveEntity( + guid, + sourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(191f, 10f, 50f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + })!; + var remote = new GameWindow.RemoteMotion(); + remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); + live.SetRemoteMotionRuntime(guid, remote); + controller = new RemoteTeleportController( + BuildEngine(includeDestination: false), + live, + (_, _) => (0.48f, 1.835f), + CellLocal, + (_, _, _) => { }, + (_, _, _) => { }, + (_, _) => { }); + + Assert.True(live.RebucketLiveEntity(guid, destinationCell)); + Assert.True(controller.TryApply( + remote, + entity, + new Vector3(193f, 10f, 50f), + destinationCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 5.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 1).Applied); + Assert.True(controller.HasPending(guid)); + + Assert.Throws(() => live.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + + Assert.True(cleanupAfterFailureRan); + Assert.False(controller.HasPending(guid)); + Assert.False(live.TryGetRecord(guid, out _)); + + LiveEntityRegistrationResult replacement = + live.RegisterLiveEntity(Spawn(guid, sourceCell)); + Assert.True(replacement.LogicalRegistrationCreated); + Assert.False(controller.HasPending(guid)); + controller.Dispose(); + } + + private static PhysicsEngine BuildEngine(bool includeDestination) + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + 0xA9B4FFFFu, + FlatTerrain(), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + if (includeDestination) + AddDestination(engine); + return engine; + } + + private static void AddDestination(PhysicsEngine engine) => + engine.AddLandblock( + 0xAAB4FFFFu, + FlatTerrain(), + Array.Empty(), + Array.Empty(), + 192f, + 0f); + + private static TerrainSurface FlatTerrain() + { + var heights = new byte[81]; + var table = new float[256]; + Array.Fill(table, 50f); + return new TerrainSurface(heights, table); + } + + private static Vector3 CellLocal(Vector3 world, uint cell) + { + int lbX = (int)(cell >> 24); + return world - new Vector3((lbX - 0xA9) * 192f, 0f, 0f); + } + + private static LoadedLandblock EmptyLandblock(uint id) => + new(id, new LandBlock(), Array.Empty()); + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 191f, 10f, 50f, 1f, 0f, 0f, 0f); + var physics = new PhysicsSpawnData( + (uint)(PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions), + position, + null, + null, + 0x02000001u, + 0x09000001u, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1)); + return new WorldSession.EntitySpawn( + Guid: guid, + Position: position, + SetupTableId: 0x02000001u, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "remote", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: physics.RawState, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private static void CreateUnloadedSourceRollback(RollbackFixture fixture) + { + Assert.True(fixture.ParkPending().Applied); + fixture.Spatial.RemoveLandblock(RollbackFixture.SourceLandblock); + fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); + Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); + Assert.Equal(0, fixture.ShadowSyncs); + Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); + } + + private static void AcceptNewerPosition(RollbackFixture fixture, uint cellId) + { + Assert.True(fixture.Live.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + RollbackFixture.Guid, + new CreateObject.ServerPosition( + cellId, 1f, 10f, 50f, 1f, 0f, 0f, 0f), + null, + null, + true, + 1, + 2, + 0, + 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + } + + private static void ApplyHidden(RollbackFixture fixture, ushort stateSequence) + { + Assert.True(fixture.Live.TryApplyState( + new SetState.Parsed( + RollbackFixture.Guid, + (uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions), + 1, + stateSequence), + out _, + out _)); + Assert.True(fixture.Presentation.OnStateAccepted(RollbackFixture.Guid)); + } + + private static void ApplyVisible(RollbackFixture fixture, ushort stateSequence) + { + Assert.True(fixture.Live.TryApplyState( + new SetState.Parsed( + RollbackFixture.Guid, + (uint)PhysicsStateFlags.ReportCollisions, + 1, + stateSequence), + out _, + out _)); + Assert.True(fixture.Presentation.OnStateAccepted(RollbackFixture.Guid)); + } + + private sealed class RollbackFixture : IDisposable + { + internal const uint Guid = 0x70000010u; + internal const uint SourceCell = 0xA9B40039u; + internal const uint SourceLandblock = 0xA9B4FFFFu; + internal const uint DestinationCell = 0xAAB40001u; + internal const uint DestinationLandblock = 0xAAB4FFFFu; + internal static readonly Vector3 SourcePosition = new(191f, 10f, 50f); + + internal RollbackFixture(bool celllessSource = false) + { + Spatial.AddLandblock(EmptyLandblock(SourceLandblock)); + Live = new LiveEntityRuntime(Spatial, new Resources()); + Live.RegisterLiveEntity(Spawn(Guid, SourceCell)); + Entity = Live.MaterializeLiveEntity( + Guid, + SourceCell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = SourcePosition, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + })!; + Remote = new GameWindow.RemoteMotion(); + Remote.Body.SnapToCell( + celllessSource ? 0u : SourceCell, + SourcePosition, + SourcePosition); + Remote.Body.TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable; + Remote.CellId = celllessSource ? 0u : SourceCell; + Live.SetRemoteMotionRuntime(Guid, Remote); + Engine.ShadowObjects.Register( + Entity.Id, + Entity.SourceGfxObjOrSetupId, + SourcePosition, + Quaternion.Identity, + 0.48f, + 0f, + 0f, + SourceLandblock, + ShadowCollisionType.Cylinder, + 1.835f, + seedCellId: SourceCell, + isStatic: false); + Assert.True(Engine.ShadowObjects.Suspend(Entity.Id)); + + Live.ProjectionVisibilityChanged += OnVisibilityChanged; + Presentation = new LiveEntityPresentationController( + Live, + Engine.ShadowObjects, + (_, _, _) => true, + liveCenter: () => (0xA9, 0xB4), + onShadowRestored: _ => ShadowSyncs++); + Assert.True(Presentation.OnLiveEntityReady(Guid)); + Controller = new RemoteTeleportController( + Engine, + Live, + (_, _) => (0.48f, 1.835f), + CellLocal, + (resolvedEntity, resolvedRemote, resolvedCell) => + { + ShadowSyncs++; + uint landblock = (resolvedCell & 0xFFFF0000u) | 0xFFFFu; + Engine.ShadowObjects.Register( + resolvedEntity.Id, + resolvedEntity.SourceGfxObjOrSetupId, + resolvedRemote.Position, + resolvedRemote.Orientation, + 0.48f, + 0f, + 0f, + landblock, + ShadowCollisionType.Cylinder, + 1.835f, + seedCellId: resolvedCell, + isStatic: false); + }, + (guid, generation, deferShadowRestore) => + Presentation.CompleteAuthoritativePlacement( + guid, + generation, + deferShadowRestore), + (guid, generation) => + Presentation.BeginAuthoritativePlacement(guid, generation), + resolvePlacement: (position, cell, _, _, _, _) => + ResolveSucceeds + ? new ResolveResult( + position, + cell, + IsOnGround: true, + InContact: true, + OnWalkable: true, + ContactPlane: new Plane(Vector3.UnitZ, 0f), + ContactPlaneCellId: cell) + : new ResolveResult(position, cell, IsOnGround: false, Ok: false)); + + if (celllessSource) + Assert.True(Live.WithdrawLiveEntityProjection(Guid)); + } + + internal GpuWorldState Spatial { get; } = new(); + internal LiveEntityRuntime Live { get; } + internal WorldEntity Entity { get; } + internal GameWindow.RemoteMotion Remote { get; } + internal PhysicsEngine Engine { get; } = new() { DataCache = new PhysicsDataCache() }; + internal RemoteTeleportController Controller { get; } + internal LiveEntityPresentationController Presentation { get; } + internal int ShadowSyncs { get; private set; } + internal bool PresentationVisible { get; private set; } = true; + internal List VisibilityEdges { get; } = []; + internal bool ResolveSucceeds { get; set; } + + internal RemoteTeleportController.Result ParkPending() + { + Controller.BeginPlacement(Guid, generation: 1); + Assert.True(Live.RebucketLiveEntity(Guid, DestinationCell)); + return Controller.TryApply( + Remote, + Entity, + new Vector3(193f, 10f, 50f), + DestinationCell, + new Vector3(1f, 10f, 50f), + Quaternion.Identity, + gameTime: 5.0, + destinationProjectionVisible: false, + generation: 1, + positionSequence: 1); + } + + public void Dispose() + { + Live.ProjectionVisibilityChanged -= OnVisibilityChanged; + Controller.Dispose(); + Presentation.Dispose(); + } + + private void OnVisibilityChanged(LiveEntityRecord _, bool visible) + { + PresentationVisible = visible; + VisibilityEdges.Add(visible); + } + } +} diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportHookTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportHookTests.cs new file mode 100644 index 00000000..6adb79b1 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportHookTests.cs @@ -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(); + 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); + } +} diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs new file mode 100644 index 00000000..a754d92d --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs @@ -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(() => + 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); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs b/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs index 937e1c17..9aa58ea3 100644 --- a/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs @@ -88,6 +88,38 @@ public sealed class AttachmentUpdateOrderTests 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(), + }; + [Fact] public void AncestorTeardownCollectsEntireAttachmentSubtreePostOrder() { diff --git a/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs b/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs new file mode 100644 index 00000000..cee150f4 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs @@ -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(); + var watcherUpdates = new List(); + + 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 hosts, + Action 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: () => { }); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs index a5db86c4..0e1d8446 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs @@ -166,6 +166,28 @@ public sealed class EntityEffectControllerTests 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] public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately() { diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs index 0d9e7b34..bc183a48 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs @@ -96,6 +96,46 @@ public sealed class RadarSnapshotProviderTests 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 + { + [player] = Entity(player, Vector3.Zero, Quaternion.Identity), + [hiddenTarget] = Entity(hiddenTarget, new Vector3(5f, 0f, 0f), Quaternion.Identity), + }; + var interactionVisible = new Dictionary(); + var spawns = new Dictionary + { + [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() { Id = guid, diff --git a/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs b/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs index dd998c50..85f5dbe3 100644 --- a/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs +++ b/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs @@ -180,9 +180,10 @@ public sealed class InboundPhysicsStateControllerTests liveVelocity, out PositionTimestampDisposition normalDisposition, out WorldSession.EntitySpawn normal, - out _)); + out AcceptedPhysicsTimestamps normalTimestamps)); Assert.Equal(PositionTimestampDisposition.Apply, normalDisposition); Assert.Equal(liveVelocity, normal.Physics!.Value.Velocity); + Assert.False(normalTimestamps.TeleportAdvanced); Assert.True(controller.TryApplyPosition( new WorldSession.EntityPositionUpdate( @@ -200,9 +201,10 @@ public sealed class InboundPhysicsStateControllerTests liveVelocity, out PositionTimestampDisposition teleportDisposition, out WorldSession.EntitySpawn teleported, - out _)); + out AcceptedPhysicsTimestamps teleportTimestamps)); Assert.Equal(PositionTimestampDisposition.Apply, teleportDisposition); Assert.Equal(Vector3.Zero, teleported.Physics!.Value.Velocity); + Assert.True(teleportTimestamps.TeleportAdvanced); } [Fact] diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs new file mode 100644 index 00000000..19ca05b3 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -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())); + + 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(), + }); + 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 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())); + 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(), + })!; + 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(), + Array.Empty(), + Array.Empty(), + null, + null, + "fixture", + null, + null, + MotionTableId: 0x09000001u, + PhysicsState: (uint)state, + InstanceSequence: instanceSequence, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + } +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index d06a39a1..ef48b2f8 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -29,6 +29,11 @@ public sealed class LiveEntityRuntimeTests private sealed class EffectProfile : ILiveEntityEffectProfile { } + private sealed class RemoteMotionRuntime : ILiveEntityRemoteMotionRuntime + { + public PhysicsBody Body { get; } = new(); + } + private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle { public int RegisterCount { get; private set; } @@ -73,6 +78,8 @@ public sealed class LiveEntityRuntimeTests spawn.Position!.Value.LandblockId, id => Entity(id, spawn.Guid)); runtime.SetAnimationRuntime(spawn.Guid, new AnimationRuntime(entity!)); + var visibilityEdges = new List(); + runtime.ProjectionVisibilityChanged += (_, visible) => visibilityEdges.Add(visible); Assert.True(registered.LogicalRegistrationCreated); Assert.Equal(1, resources.RegisterCount); @@ -90,6 +97,7 @@ public sealed class LiveEntityRuntimeTests Assert.Single(spatial.Entities); Assert.Equal(1, resources.RegisterCount); Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _)); + Assert.Equal(new[] { false, true }, visibilityEdges); } [Fact] @@ -296,6 +304,7 @@ public sealed class LiveEntityRuntimeTests Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved)); Assert.Same(attached, resolved); Assert.Equal(1, resources.RegisterCount); + attached.IsAncestorDrawVisible = false; WorldEntity same = runtime.MaterializeLiveEntity( spawn.Guid, @@ -304,6 +313,7 @@ public sealed class LiveEntityRuntimeTests LiveEntityProjectionKind.World)!; Assert.Same(attached, same); + Assert.True(same.IsAncestorDrawVisible); Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value); Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); @@ -500,6 +510,210 @@ public sealed class LiveEntityRuntimeTests 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] public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity() { @@ -792,14 +1006,15 @@ public sealed class LiveEntityRuntimeTests uint guid, ushort instance, ushort positionSequence, - uint cell) + uint cell, + PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions) { var position = new CreateObject.ServerPosition( cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); var timestamps = new PhysicsTimestamps( positionSequence, 1, 1, 1, 0, 1, 0, 1, instance); var physics = new PhysicsSpawnData( - RawState: (uint)PhysicsStateFlags.ReportCollisions, + RawState: (uint)state, Position: position, Movement: null, AnimationFrame: null, @@ -832,7 +1047,7 @@ public sealed class LiveEntityRuntimeTests null, null, 0x09000001u, - PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + PhysicsState: (uint)state, InstanceSequence: instance, MovementSequence: 1, ServerControlSequence: 1, diff --git a/tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs b/tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs index 23bd3c64..ab1a0fa7 100644 --- a/tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs +++ b/tests/AcDream.Core.Tests/Physics/HandleAllCollisionsTests.cs @@ -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) 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); + } } diff --git a/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs b/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs index fb243f39..fd3b6e9c 100644 --- a/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs +++ b/tests/AcDream.Core.Tests/Physics/InitialPlacementOverlapTests.cs @@ -50,6 +50,9 @@ public sealed class InitialPlacementOverlapTests movingEntityId: PlayerId); Assert.True(result.Ok); + Assert.True(result.InContact); + Assert.True(result.OnWalkable); + Assert.Equal(Cell, result.ContactPlaneCellId); Assert.NotEqual(savedFeet, result.Position); float centerDistance = Vector3.Distance( result.Position + new Vector3(0f, 0f, Radius), diff --git a/tests/AcDream.Core.Tests/Physics/RetailPhysicsStateTransitionTests.cs b/tests/AcDream.Core.Tests/Physics/RetailPhysicsStateTransitionTests.cs new file mode 100644 index 00000000..ea6c2720 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailPhysicsStateTransitionTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs index 4f17cd6a..208b5893 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs @@ -102,6 +102,46 @@ public sealed class WbDrawDispatcherBucketingTests 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 { 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 { suppressed.Id }); + + Assert.Equal(0, result.EntitiesWalked); + Assert.Empty(result.ToDraw); + } + [Fact] public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities() {