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()
{