feat(vfx): port retail hidden and teleport presentation

Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
This commit is contained in:
Erik 2026-07-14 14:59:48 +02:00
parent a51ebc66e9
commit 1e98d81448
46 changed files with 4883 additions and 127 deletions

View file

@ -181,6 +181,14 @@ src/
AcDream.App/ Layer 1 + Layer 4 wiring AcDream.App/ Layer 1 + Layer 4 wiring
Physics/ Physics/
ProjectileController.cs -> live-record projectile orchestration/corrections ProjectileController.cs -> live-record projectile orchestration/corrections
RemotePhysicsUpdater.cs -> ordinary/Hidden remote narrow-tick integration
RemoteTeleportController.cs -> incarnation-scoped loaded/pending placement owner
RemoteTeleportHook.cs -> ordered retail teleport teardown seam
RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit
World/
LiveEntityRuntime.cs -> canonical logical identity/state/spatial ownership
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
Rendering/ Rendering/
GameWindow.cs -> still owns too much runtime wiring GameWindow.cs -> still owns too much runtime wiring
TerrainRenderer.cs -> done TerrainRenderer.cs -> done
@ -248,6 +256,36 @@ What exists and is active:
owns the frame; owns the frame;
delete, generation replacement, pickup/parent leave-world, and session reset delete, generation replacement, pickup/parent leave-world, and session reset
use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map. use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map.
- `RemoteTeleportController` owns the placement half of a fresh remote
teleport after `RemoteTeleportHook` has torn down movement/target state. It
collision-seats loaded destinations through `RemoteTeleportPlacement`; an
unloaded destination retains one generation- and PositionSequence-scoped
pending placement and resolves the latest accepted frame when that same
projection becomes visible. It neither owns GUID identity nor reconstructs
an entity. A placement failure after hydration restores the captured source
frame/cell/contact rather than leaving a visible collisionless projection;
source-resident shadows restore immediately, while an unloaded source
delegates one incarnation-scoped restore to
`LiveEntityPresentationController`, shared with Hidden/UnHide. A newer
placement transfers that marker into an explicit active-placement generation
before rebucketing even while Hidden. That generation suppresses every
intervening Hidden/UnHide and projection restore until the controller reaches
a stable result, then it either restores after collision seating, re-defers
its rollback source, or hands a Hidden result back for UnHide. The typed
`ILiveEntityRemotePlacementRuntime` seam permits a same-incarnation wrapper
rebind only around the canonical body; pending placement adopts that wrapper
before hydration and cannot silently lose ownership. Clearing a motion or
projectile component retains the incarnation's body/contract identity until
logical teardown. The production wrapper exposes an immutable body, while
hydration defensively validates arbitrary implementations against the record
and rolls the retained body back on mismatch. Runtime binding snapshots the
interface Body getter once for validation, assignment, and state mutation.
`GpuWorldState`
rebuckets atomically and commits spatial visibility before draining its
transition FIFO; `LiveEntityRuntime` rejects delayed duplicate edges. A
rollback/rebucket inside a visibility observer therefore cannot expose a
remove/add pulse, leave stale final visibility, or reorder the final
presentation edge.
- `BSPQuery` contains the partial retail-style BSP collision dispatcher used by - `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
the transition path. the transition path.
- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`, - `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,
@ -296,7 +334,15 @@ owns spatial buckets only: register/rebucket/unregister are separate operations,
and landblock reloads reuse the same `WorldEntity` without replaying renderer or and landblock reloads reuse the same `WorldEntity` without replaying renderer or
script creation. Its canonical materialized view remains stable across pending script creation. Its canonical materialized view remains stable across pending
landblocks, while a separate visible-only view feeds radar, picking, status, and landblocks, while a separate visible-only view feeds radar, picking, status, and
targeting. Pickup/parent leave-world clears cell membership and pauses root targeting. Raw server PhysicsState and the final state produced by retail's
ordered Lighting/NoDraw/Hidden side effects are stored separately.
`LiveEntityPresentationController` drains those accepted transitions only after
the renderer/effect owner is ready: Hidden suppresses the retained root's mesh,
collision, interaction, radar, and target eligibility while preserving the
logical record, scripts, particles, lights, full cell, and local identity.
Direct attached children receive retail's NoDraw mutation, and typed
Hidden/UnHide effects resolve through the live PhysicsScriptTable.
Pickup/parent leave-world clears cell membership and pauses root
movement/animation without destroying retained owners. `GameWindow` retains movement/animation without destroying retained owners. `GameWindow` retains
storage-free typed views while its large feature loops are extracted. storage-free typed views while its large feature loops are extracted.

View file

@ -187,9 +187,17 @@ src/AcDream.App/
│ └── Vfx/ # (already exists) │ └── Vfx/ # (already exists)
├── Net/ ├── Net/
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect │ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
├── Physics/
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
│ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions
│ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit
├── World/ ├── World/
│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots │ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation │ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation
│ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects
│ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations │ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations
├── Interaction/ ├── Interaction/
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch │ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
@ -231,7 +239,11 @@ instead of spread across `GameWindow`.
`InboundPhysicsStateController` for the nine-channel retail timestamp gates and `InboundPhysicsStateController` for the nine-channel retail timestamp gates and
latest accepted immutable CreateObject snapshot, owns the canonical local ID latest accepted immutable CreateObject snapshot, owns the canonical local ID
and optional runtime components, and separates logical registration from and optional runtime components, and separates logical registration from
spatial projection. `ParentAttachmentState` is runtime-owned and keys unresolved spatial projection. It also retains raw PhysicsState separately from the final
state produced by retail's ordered side effects;
`LiveEntityPresentationController` projects those transitions into draw,
collision, effect, child-NoDraw, and target visibility without becoming a
second lifetime or GUID owner. `ParentAttachmentState` is runtime-owned and keys unresolved
relations by child and parent generation. `Rendering/Vfx/EntityEffectController` relations by child and parent generation. `Rendering/Vfx/EntityEffectController`
owns the focused mixed F754/F755 pending FIFO, effect profiles, typed-table owns the focused mixed F754/F755 pending FIFO, effect profiles, typed-table
resolution, and a readiness set; canonical ServerGuid-to-local-ID translation resolution, and a readiness set; canonical ServerGuid-to-local-ID translation
@ -241,6 +253,35 @@ allocators fail fast instead of wrapping into another landblock's namespace.
All other non-Parent packet All other non-Parent packet
families still need the future general queue tracked by divergence AD-32. families still need the future general queue tracked by divergence AD-32.
Remote teleport placement is bounded in `Physics/RemoteTeleportController`,
not `GameWindow`: it retains at most one pending request per materialized
incarnation, scopes it by the live generation and accepted PositionSequence,
and asks `RemoteTeleportPlacement` to collision-seat the current body when the
destination projection is available. `GameWindow` supplies lifecycle and
shadow-sync callbacks only; canonical identity remains in `LiveEntityRuntime`.
Failed hydration restores the captured source and delegates an
incarnation-scoped shadow restore to `LiveEntityPresentationController` while
that source is unloaded, so Hidden/UnHide and teleport never become competing
restore owners. A newer placement transfers that restore into an explicit
generation-scoped active-placement state before its rebucket visibility edge
even while Hidden. All intervening Hidden/UnHide and projection edges defer to
that owner until stable success or rollback completes; only then can it restore,
re-defer the source, or hand a Hidden result back for UnHide. The
`ILiveEntityRemotePlacementRuntime` seam keeps the complete cell/contact
handoff available across same-body runtime-wrapper replacement; replacing the
canonical body or dropping the placement contract within one incarnation is
rejected even after an operational component clear. `RemoteMotion.Body` is
constructor-owned; hydration compares pending/current wrappers directly to the
record body rather than trusting wrapper-to-wrapper equality. Binding reads an
interface Body getter once and reuses that snapshot. `GpuWorldState`
performs remove+place as one spatial rebucket,
then commits and serially drains visibility edges; `LiveEntityRuntime` filters
delayed duplicates. A rollback inside an observer cannot race the outer
destination-visible notification or expose an intermediate false pulse.
`LiveEntityTeardown` executes those independent owner callbacks to completion
and aggregates failures afterwards, so a throwing effect/plugin sink cannot
strand teleport, movement, shadow, light, or GUID-scoped state.
--- ---
## 4. Extraction sequence — safest first ## 4. Extraction sequence — safest first

View file

@ -253,9 +253,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-44 | NPC UpdatePosition reconciliation (position + orientation) is SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`). **#184 (2026-07-07) UNIFIED the NPC path onto the interp queue** — the retire-condition mechanism is now ported: the grounded UP routes through `CPhysicsObj::MoveOrTeleport` (Enqueue) and the per-tick catch-up SEEDS the `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES it while armed (0x00555190 / 0x00555430 assigns m_fOrigin), exactly like the player-remote branch — so POSITION is now handled retail-faithfully by the compose-overwrite whether the gate is on or off. The `snapSuppressedByStick` gate is RETAINED for two residuals: (a) it suppresses the during-stick **Enqueue** so a stale server waypoint never enters the queue, and (b) it gates the **orientation** hard-snap (still outside the interp chain — retail's sticky owns facing). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's position mechanism (sticky-overwrites-interp) is now REACHABLE for NPCs (#184 gave them the interp-queue architecture); the gate remains only for orientation + during-stick Enqueue cleanliness | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target) keeps a stale orientation until unstick+next UP; bounded by the 1 s lease | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330 (NPC UP routing, #184); retire the orientation residual in R6 | | TS-44 | NPC UpdatePosition reconciliation (position + orientation) is SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`). **#184 (2026-07-07) UNIFIED the NPC path onto the interp queue** — the retire-condition mechanism is now ported: the grounded UP routes through `CPhysicsObj::MoveOrTeleport` (Enqueue) and the per-tick catch-up SEEDS the `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES it while armed (0x00555190 / 0x00555430 assigns m_fOrigin), exactly like the player-remote branch — so POSITION is now handled retail-faithfully by the compose-overwrite whether the gate is on or off. The `snapSuppressedByStick` gate is RETAINED for two residuals: (a) it suppresses the during-stick **Enqueue** so a stale server waypoint never enters the queue, and (b) it gates the **orientation** hard-snap (still outside the interp chain — retail's sticky owns facing). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's position mechanism (sticky-overwrites-interp) is now REACHABLE for NPCs (#184 gave them the interp-queue architecture); the gate remains only for orientation + during-stick Enqueue cleanliness | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target) keeps a stale orientation until unstick+next UP; bounded by the 1 s lease | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330 (NPC UP routing, #184); retire the orientation residual in R6 |
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal rε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` | | TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal rε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | | ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition``PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — teleport freshness is gated, but no remote manager teardown) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second. The retail timestamp distinction now exists; Step 8 must connect its accepted-teleport disposition to the hook teardown. | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; `SmartBox::HandleReceivedPosition` 0x00453FD0 |
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
--- ---

View file

@ -517,6 +517,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant. - **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant.
- **Missile/portal VFX campaign Step 7 implemented and independently reviewed 2026-07-14.** `ProjectileController` now attaches the Step 6 body to the canonical `LiveEntityRecord` after materialization and advances arrows, bolts, and spell missiles without another GUID map, renderer registration, or stale-spawn reconstruction. It commits predicted frames through `WorldEntity.SetPosition`, republishes static effect roots, and keeps collision shadows plus canonical full-cell buckets synchronized. A predicted crossing into an unloaded bucket suspends the body and shadow in that same quantum; pending/pickup residence retains the shadow registration for exact re-entry, and hydration plus the 96-unit retail activity gate restart at the current clock without backlog. Fresh State/Vector/Position/Movement packets correct or stop the same body after retail timestamp gates; SetState reaches the canonical body before optional projectile acquisition, and a non-finite local receipt clock cannot consume first classification. Removing Missile retains ordinary active-body physics (delegating to MovementManager when present), while both owners share the body and incarnation-scoped live-record cell. CreateObject vectors are installed only at body construction, never replayed by later SetState. Delete, reset, and GUID reuse use normal logical teardown; ACE remains authoritative for impact, damage, effects, and deletion. - **Missile/portal VFX campaign Step 7 implemented and independently reviewed 2026-07-14.** `ProjectileController` now attaches the Step 6 body to the canonical `LiveEntityRecord` after materialization and advances arrows, bolts, and spell missiles without another GUID map, renderer registration, or stale-spawn reconstruction. It commits predicted frames through `WorldEntity.SetPosition`, republishes static effect roots, and keeps collision shadows plus canonical full-cell buckets synchronized. A predicted crossing into an unloaded bucket suspends the body and shadow in that same quantum; pending/pickup residence retains the shadow registration for exact re-entry, and hydration plus the 96-unit retail activity gate restart at the current clock without backlog. Fresh State/Vector/Position/Movement packets correct or stop the same body after retail timestamp gates; SetState reaches the canonical body before optional projectile acquisition, and a non-finite local receipt clock cannot consume first classification. Removing Missile retains ordinary active-body physics (delegating to MovementManager when present), while both owners share the body and incarnation-scoped live-record cell. CreateObject vectors are installed only at body construction, never replayed by later SetState. Delete, reset, and GUID reuse use normal logical teardown; ACE remains authoritative for impact, damage, effects, and deletion.
- **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains because the separate 25-second/384-metre liveness cull is still unported.
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.

View file

@ -471,8 +471,8 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade `PlayerDescription.Options1` preserves retail's player secure-trade
preference. See preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
- **M2/M3 missile, effect, and portal presentation campaign (Steps 07 - **M2/M3 missile, effect, and portal presentation campaign (Steps 08
implemented and independently reviewed 2026-07-14)** — named-retail projectile and independently reviewed 2026-07-14)** — named-retail projectile and
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc` physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
and F754/F755 wire surfaces are parsed with retail timestamp gates, and and F754/F755 wire surfaces are parsed with retail timestamp gates, and
`LiveEntityRuntime` now separates logical lifetime from spatial rebucketing. `LiveEntityRuntime` now separates logical lifetime from spatial rebucketing.
@ -526,8 +526,15 @@ include dungeons.
if Missile classification changes while pending; static missile effect if Missile classification changes while pending; static missile effect
roots follow every predicted frame, and materialized rehydration never uses a roots follow every predicted frame, and materialized rehydration never uses a
stale spawn cell. No impact, damage, effect, or delete event is synthesized stale spawn cell. No impact, damage, effect, or delete event is synthesized
client-side. The remaining steps port client-side. Step 8 ports retail's constructor-state → PhysicsDesc
Hidden/UnHide portal presentation and run final hardening/visual gates. transition, ordered Lighting/NoDraw/Hidden state side effects, DAT-driven
typed Hidden/UnHide effects, direct-child NoDraw propagation, retained
particles/lights, mesh/collision/interaction suppression, and exact remote
`teleport_hook` teardown followed by a placement snap before the airborne
gate. Ordinary visible CreateObject does not fabricate UnHide; pending
F754/F755 packets replay only after construction-state effects. TS-43 is
retired. The remaining Step 9 is final hardening plus the two-client visual
gate.
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** - **L.1c local attack receive path (implemented 2026-07-11; live gate pending)**
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
funnel and action-stamp gate, so ACE's server-selected melee/missile action funnel and action-stamp gate, so ACE's server-selected melee/missile action

View file

@ -698,8 +698,110 @@ The recall Animation's direct DAT hooks must be executed rather than replaced
with a guessed cloud. Hidden/UnHide state transitions and explicit typed with a guessed cloud. Hidden/UnHide state transitions and explicit typed
scripts remain distinct packet-driven events. A normal visible spawn alone is scripts remain distinct packet-driven events. A normal visible spawn alone is
not evidence for an UnHide materialization effect; the observer portal-in not evidence for an UnHide materialization effect; the observer portal-in
sequence must be validated against its actual inbound state/effect packets sequence remains a final two-client packet/visual gate.
during Step 8.
### 11.5 Step 8 implementation boundary
The port keeps raw wire state and side-effect-derived final state separate.
`LiveEntityPresentationController` drains accepted state transitions only after
the renderer, effect profile, and PhysicsScript owner are ready; pending mixed
F754/F755 packets replay after that construction-state drain. Hidden removes
the retained entity from drawing, collision, radar, picking, status, and new
target acquisition, but does not unregister its record, cell, script queue,
particles, lights, or canonical local ID. Attached children receive the exact
direct NoDraw mutation.
The Hidden update is a narrow tick, not a frozen entity. In
`CPhysicsObj::UpdatePositionInternal` (`0x00512C30`) Hidden skips PartArray
root-motion/animation advancement and `UpdatePhysicsInternal`, but still
composes `PositionManager::adjust_offset` and processes timed object hooks.
The surrounding `UpdateObjectInternal` (`0x005156B0`) then continues the
target, movement, and position-manager tails. A moving Hidden local or remote
therefore updates its root/cell and effect-pose snapshot without accepting
input, gravity, or ordinary body integration.
Retail `CObjCell::hide_object` (`0x0052BE30`) also sends DetectionManager
`LeftDetection`. DetectionManager is not yet ported, so TS-49 records the one
explicit adaptation: acdream sends the existing TargetManager non-Ok
availability edge to tear down MoveTo/Sticky consumers and clears the hidden
object's watched-role subscriptions.
Fresh remote `TELEPORT_TS`, or first placement while the canonical cell is
zero, follows `MoveOrTeleport` Branch A before its contact test:
```text
CancelMoveTo(0x3c)
UnStick
StopInterpolating
UnConstrain
ClearTarget + NotifyVoyeurOfEvent(Teleported)
report_collision_end
SetPosition the same body at received full cell + frame through CTransition
derive Contact/OnWalkable/contact plane and ground-transition state
```
This order is required even for an airborne packet; the ordinary airborne
no-op is only a Branch B/C decision and cannot undo a genuine teleport. A
canonical nonzero cell whose spatial projection is unavailable is classified
as cell-less for this decision: it cannot take the ordinary interpolation path
without a resident cell.
If the destination landblock is pending, `RemoteTeleportController` parks the
authoritative frame without contact, retains the original contact edge, and
waits for projection visibility. The request is scoped to live generation and
accepted PositionSequence. Every newer accepted Position packet refreshes that
same request; hydration collision-seats only the latest sequence, while delete,
reset, or GUID reuse discards the old incarnation's placement.
`SetPositionInternal` captures the old Contact/OnWalkable flags at entry. It
writes destination Contact while retaining the source OnWalkable bit for the
first `calc_acceleration`, then invokes `set_on_walkable` (therefore
HitGround/LeaveGround), and only then calls `handle_all_collisions` at
`0x005154FE`. Retaining source walkability through that first calculation
matters for a grounded source that hydrates onto a steep contact: retail clears
grounded angular drift before installing the destination's non-walkable state.
The callback may change velocity, so collision response must consume that
post-callback velocity while still receiving the captured source flags. This
ordering is shared by loaded teleports and local/remote Hidden PositionManager
movement. A Hidden projectile that shares the same RemoteMotion body also uses
this narrow manager tick once; projectile integration remains paused.
If projection hydration occurs but placement resolution fails, the visibility
edge cannot be treated as a retry clock. The controller restores the captured
source body frame, full cell, contact state, and projection, then drops the
pending request. A resident source restores its shadow immediately; an unloaded
source delegates one generation-scoped restore to
`LiveEntityPresentationController`, the same owner used by Hidden/UnHide, until
that projection returns. Before any newer accepted placement rebuckets, it
transfers the deferred restore into an explicit active-placement generation,
including while Hidden. That ownership remains active for the complete pending
lifetime, so any number of intervening Hidden/UnHide or visibility edges cannot
restore an unresolved pose. Only the controller's stable completion clears it:
the placement either restores after successful collision seating, re-defers its
captured source after failure, or hands a stable Hidden result back for UnHide.
A same-incarnation MovementManager/runtime rebind cannot replace the canonical
physics body or discard the typed placement contract. A wrapper replacement
around that same body is adopted by the pending request before hydration, so
component rebinding cannot strand active-placement ownership. Operational
component clearing removes the wrapper, not the incarnation's body identity or
placement requirement; only logical teardown releases those invariants. The
production wrapper's body is constructor-owned and immutable, and hydration
validates both pending/current wrappers against the record's captured body. A
malformed dynamic wrapper is detached and the canonical body is rolled back.
Every runtime-binding boundary reads the wrapper's Body exactly once, validates
that local snapshot, and installs the same reference into the live record.
A cell-less source withdraws the projection and publishes the final
invisible presentation edge. Spatial rebucketing removes
and places atomically, visibility transitions commit their new set first and
drain through a non-reentrant FIFO, and `LiveEntityRuntime` rejects delayed
duplicates against its last logical state. A rollback triggered by a
destination-visible callback therefore cannot expose remove/add pulses or be
overwritten by the outer notification.
A newer loaded destination that fails placement consumes the original pending
request and performs the same rollback. Logical teardown is independently
failure-isolated across presentation, effects, teleport, movement/target,
shadow, and light owners; session reset clears pending teleport state even when
another teardown callback throws.
## 12. Secondary-reference cross-checks ## 12. Secondary-reference cross-checks

View file

@ -149,6 +149,17 @@ public sealed class PlayerMovementController
/// </summary> /// </summary>
public uint LocalEntityId { get; set; } public uint LocalEntityId { get; set; }
/// <summary>
/// Applies the canonical server PhysicsState to the local body. Retail's
/// <c>CPhysicsObj::SetState</c> replaces these persistent bits before its
/// next acceleration/integration decision.
/// </summary>
public void ApplyPhysicsState(PhysicsStateFlags state)
{
_body.State = state;
_body.calc_acceleration();
}
public bool IsAirborne => !_body.OnWalkable; public bool IsAirborne => !_body.OnWalkable;
/// <summary> /// <summary>
@ -571,6 +582,86 @@ public sealed class PlayerMovementController
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
} }
/// <summary>
/// Retail Hidden slice of <c>CPhysicsObj::UpdatePositionInternal</c>
/// (0x00512C30). Input, PartArray root motion, and physics integration are
/// skipped, while PositionManager composition and the manager tail remain
/// live. A composed offset still commits through CTransition so cell
/// membership and contact state cannot become stale behind the hidden mesh.
/// </summary>
public MovementResult TickHidden(float dt)
{
_simTimeSeconds += dt;
_physicsAccum = 0f;
Vector3 previousPosition = _body.Position;
bool previousContact = _body.InContact;
bool previousOnWalkable = _body.OnWalkable;
if (PositionManager is { } manager)
{
var delta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
manager.AdjustOffset(delta, dt);
if (delta.Origin != Vector3.Zero)
_body.Position += Vector3.Transform(delta.Origin, _body.Orientation);
if (!delta.Orientation.IsIdentity)
{
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
+ delta.GetHeading());
_body.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
Yaw - MathF.PI / 2f);
}
}
if (_body.Position != previousPosition && CellId != 0 && _physics.LandblockCount > 0)
{
ResolveResult resolved = _physics.ResolveWithTransition(
previousPosition,
_body.Position,
CellId,
sphereRadius: 0.48f,
sphereHeight: 1.835f,
stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight,
isOnGround: previousOnWalkable,
body: _body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: LocalEntityId);
_body.Position = resolved.Position;
PhysicsObjUpdate.CommitSetPositionTransition(
_body,
resolved.InContact,
resolved.OnWalkable,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
Movement.HitGround,
_motion.LeaveGround);
UpdateCellId(resolved.CellId, "hidden-position-manager");
}
Movement.UseTime();
PositionManager?.UseTime();
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
_wasAirborneLastFrame = !_body.OnWalkable;
return new MovementResult(
Position: _body.Position,
RenderPosition: _body.Position,
CellId: CellId,
IsOnGround: _body.OnWalkable,
MotionStateChanged: false,
ForwardCommand: null,
SidestepCommand: null,
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
TurnSpeed: null);
}
public MovementResult Update(float dt, MovementInput input) public MovementResult Update(float dt, MovementInput input)
{ {
_simTimeSeconds += dt; _simTimeSeconds += dt;

View file

@ -251,7 +251,7 @@ internal sealed class ProjectileController
entity.MeshRefs.Count > 0); entity.MeshRefs.Count > 0);
_liveEntities.SetProjectileRuntime(record.ServerGuid, runtime); _liveEntities.SetProjectileRuntime(record.ServerGuid, runtime);
if (HasVisibleCell(record)) if (HasVisibleCell(record) && !IsHidden(record))
{ {
ShadowPositionSynchronizer.Sync( ShadowPositionSynchronizer.Sync(
_shadows, _shadows,
@ -262,6 +262,15 @@ internal sealed class ProjectileController
liveCenterX, liveCenterX,
liveCenterY); liveCenterY);
} }
else if (HasVisibleCell(record))
{
// Hidden is a retained in-world CPhysicsObj, not leave_world.
// Keep Active/identity but remove collision rows and consume the
// hidden clock so UnHide cannot replay a time backlog.
body.InWorld = true;
body.LastUpdateTime = currentTime;
_shadows.Suspend(entity.Id);
}
else else
{ {
SuspendOutsideWorld(runtime, entity.Id); SuspendOutsideWorld(runtime, entity.Id);
@ -300,7 +309,8 @@ internal sealed class ProjectileController
SetVelocity(runtime.Body, velocity, currentTime); SetVelocity(runtime.Body, velocity, currentTime);
runtime.Body.Omega = angularVelocity; runtime.Body.Omega = angularVelocity;
if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)) if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)
&& !IsHidden(record))
Deactivate(runtime.Body); Deactivate(runtime.Body);
return true; return true;
} }
@ -425,7 +435,7 @@ internal sealed class ProjectileController
entity.Rotation = orientation; entity.Rotation = orientation;
entity.ParentCellId = canonicalCellId; entity.ParentCellId = canonicalCellId;
_liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId); _liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId);
if (HasVisibleCell(record)) if (HasVisibleCell(record) && !IsHidden(record))
{ {
if (!wasInWorld) if (!wasInWorld)
Activate(body, currentTime); Activate(body, currentTime);
@ -439,6 +449,12 @@ internal sealed class ProjectileController
liveCenterX, liveCenterX,
liveCenterY); liveCenterY);
} }
else if (HasVisibleCell(record))
{
body.InWorld = true;
body.LastUpdateTime = currentTime;
_shadows.Suspend(entity.Id);
}
else else
{ {
SuspendOutsideWorld(runtime, entity.Id); SuspendOutsideWorld(runtime, entity.Id);
@ -496,6 +512,27 @@ internal sealed class ProjectileController
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
continue; continue;
runtime.Body.State = record.FinalPhysicsState;
if (IsHidden(record))
{
if (!HasVisibleCell(record))
{
SuspendOutsideWorld(runtime, entity.Id);
}
else
{
// UpdatePositionInternal skips root physics/PartArray
// while Hidden, but update_object still advances its
// clock and does not clear Active.
runtime.Body.InWorld = true;
runtime.Body.LastUpdateTime = currentTime;
if ((record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0)
Deactivate(runtime.Body);
_shadows.Suspend(entity.Id);
}
continue;
}
bool isMissile = bool isMissile =
(record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0; (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0;
if (!isMissile) if (!isMissile)
@ -530,7 +567,6 @@ internal sealed class ProjectileController
continue; continue;
} }
runtime.Body.State = record.FinalPhysicsState;
if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)) if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid))
{ {
if (!HasVisibleCell(record)) if (!HasVisibleCell(record))
@ -637,6 +673,9 @@ internal sealed class ProjectileController
&& record.IsSpatiallyVisible && record.IsSpatiallyVisible
&& record.FullCellId != 0; && record.FullCellId != 0;
private static bool IsHidden(LiveEntityRecord record) =>
(record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
private void SuspendOutsideWorld(Runtime runtime, uint localEntityId) private void SuspendOutsideWorld(Runtime runtime, uint localEntityId)
{ {
runtime.Body.InWorld = false; runtime.Body.InWorld = false;

View file

@ -11,6 +11,8 @@ namespace AcDream.App.Physics;
/// frame, from inside the same guard the body used to live under /// frame, from inside the same guard the body used to live under
/// (<c>ae.Sequencer != null &amp;&amp; serverGuid != 0 &amp;&amp; serverGuid != _playerServerGuid /// (<c>ae.Sequencer != null &amp;&amp; serverGuid != 0 &amp;&amp; serverGuid != _playerServerGuid
/// &amp;&amp; rm.LastServerPosTime &gt; 0</c>). /// &amp;&amp; rm.LastServerPosTime &gt; 0</c>).
/// Hidden remotes use a separate live-entity pass because retail keeps their
/// PositionManager alive even when the object has no render-animation owner.
/// ///
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED /// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the /// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
@ -55,6 +57,35 @@ internal sealed class RemotePhysicsUpdater
// Duplicated one-liner (GameWindow keeps its own copy — many callers there). // Duplicated one-liner (GameWindow keeps its own copy — many callers there).
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
/// <summary>
/// Advances the retained PositionManager path for every hidden remote,
/// including objects without an <c>AnimatedEntity</c> (missiles commonly
/// have no PartArray). Retail gates this path on the live CPhysicsObj, not
/// on whether a render-animation owner exists.
/// </summary>
public void TickHiddenEntities(
AcDream.App.World.LiveEntityRuntime liveEntities,
uint localPlayerServerGuid,
float dt,
System.Action<AcDream.Core.World.WorldEntity> publishRootPose)
{
ArgumentNullException.ThrowIfNull(liveEntities);
ArgumentNullException.ThrowIfNull(publishRootPose);
foreach (AcDream.App.World.LiveEntityRecord record in liveEntities.Records.ToArray())
{
if (record.ServerGuid == localPlayerServerGuid
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|| record.RemoteMotionRuntime is not RemoteMotion remote
|| record.WorldEntity is not { } entity)
continue;
TickHidden(remote, entity, dt);
publishRootPose(entity);
}
}
/// <summary> /// <summary>
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c> /// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
/// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard /// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard
@ -556,6 +587,99 @@ internal sealed class RemotePhysicsUpdater
rm.Host?.PositionManager.UseTime(); rm.Host?.PositionManager.UseTime();
} }
/// <summary>
/// Retail hidden-object slice of <c>CPhysicsObj::UpdatePositionInternal</c>
/// (<c>0x00512C30</c>) plus the manager tail of
/// <c>UpdateObjectInternal</c> (<c>0x005156B0</c>). Hidden skips
/// <c>CPartArray::Update</c> and <c>UpdatePhysicsInternal</c>, but the
/// PositionManager offset is still composed and the target, movement, and
/// position managers still consume time. Physics-script and particle owners
/// tick later in the shared frame pipeline.
/// </summary>
public void TickHidden(
RemoteMotion rm,
AcDream.Core.World.WorldEntity entity,
float dt)
{
ArgumentNullException.ThrowIfNull(rm);
ArgumentNullException.ThrowIfNull(entity);
System.Numerics.Vector3 preComposePosition = rm.Body.Position;
// The part-array contribution is the identity frame while Hidden.
// Interpolation is the first PositionManager stage in retail; acdream
// retains it in RemoteMotionCombiner, ahead of Sticky/Constraint.
System.Numerics.Vector3 interpolationOffset = rm.Position.ComputeOffset(
dt,
rm.Body.Position,
System.Numerics.Vector3.Zero,
rm.Body.Orientation,
rm.Interp,
rm.Motion.GetMaxSpeed());
var positionDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
{
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
rm.Body.Orientation,
interpolationOffset),
};
rm.Host?.PositionManager.AdjustOffset(positionDelta, dt);
ApplyPositionManagerDelta(rm.Body, positionDelta);
System.Numerics.Vector3 composedPosition = rm.Body.Position;
if (rm.CellId != 0
&& composedPosition != preComposePosition
&& _physicsEngine.LandblockCount > 0)
{
var (radius, height) = _getSetupCylinder(entity.ServerGuid, entity);
if (radius < 0.05f)
{
radius = 0.48f;
height = 1.835f;
}
bool previousContact = rm.Body.InContact;
bool previousOnWalkable = rm.Body.OnWalkable;
var resolved = _physicsEngine.ResolveWithTransition(
preComposePosition,
composedPosition,
rm.CellId,
radius,
height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: previousOnWalkable,
body: rm.Body,
moverFlags: IsPlayerGuid(entity.ServerGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
movingEntityId: entity.Id);
rm.Body.Position = resolved.Position;
if (resolved.CellId != 0)
rm.CellId = resolved.CellId;
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
rm.Body,
resolved.InContact,
resolved.OnWalkable,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable,
rm.Movement.HitGround,
rm.Motion.LeaveGround);
rm.Airborne = !rm.Body.OnWalkable;
}
entity.SetPosition(rm.Body.Position);
if (rm.CellId != 0)
entity.ParentCellId = rm.CellId;
entity.Rotation = rm.Body.Orientation;
rm.Host?.HandleTargetting();
TickRemoteMoveTo(rm);
rm.Host?.PositionManager.UseTime();
}
/// <summary> /// <summary>
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/> /// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto /// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
@ -605,16 +729,36 @@ internal sealed class RemotePhysicsUpdater
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC /// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
/// UP-branch tail. /// UP-branch tail.
/// </summary> /// </summary>
public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY) public void SyncRemoteShadowToBody(
uint entityId,
AcDream.App.World.ILiveEntityRemotePlacementRuntime rm,
int liveCenterX,
int liveCenterY,
uint? authoritativeCellId = null)
{
SyncRemoteShadowToBody(
entityId,
rm.Body,
liveCenterX,
liveCenterY,
authoritativeCellId ?? rm.CellId);
rm.LastShadowSyncPosition = rm.Body.Position;
}
public void SyncRemoteShadowToBody(
uint entityId,
AcDream.Core.Physics.PhysicsBody body,
int liveCenterX,
int liveCenterY,
uint authoritativeCellId)
{ {
ShadowPositionSynchronizer.Sync( ShadowPositionSynchronizer.Sync(
_physicsEngine.ShadowObjects, _physicsEngine.ShadowObjects,
entityId, entityId,
rm.Body.Position, body.Position,
rm.Body.Orientation, body.Orientation,
rm.CellId, authoritativeCellId,
liveCenterX, liveCenterX,
liveCenterY); liveCenterY);
rm.LastShadowSyncPos = rm.Body.Position;
} }
} }

View file

@ -0,0 +1,444 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Physics;
/// <summary>
/// Owns the SetPosition half of retail remote
/// <c>CPhysicsObj::MoveOrTeleport</c> (0x00516330). It resolves the
/// destination through the placement transition, commits that transition to
/// the existing body, and synchronizes the remote movement/contact state.
/// Logical identity, target-hook actions, rebucketing callbacks, and render
/// presentation remain with their existing owners.
/// </summary>
internal sealed class RemoteTeleportController : IDisposable
{
internal delegate ResolveResult PlacementResolver(
Vector3 position,
uint cellId,
float radius,
float height,
ObjectInfoState moverFlags,
uint movingEntityId);
private readonly PhysicsEngine _physics;
private readonly LiveEntityRuntime _liveEntities;
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _getSetupCylinder;
private readonly Func<Vector3, uint, Vector3> _cellLocalForSeed;
private readonly Action<WorldEntity, PhysicsBody, uint> _syncResolvedShadow;
private readonly Action<uint, ushort, bool> _completeAuthoritativePlacement;
private readonly Action<uint, ushort> _beginAuthoritativePlacement;
private readonly PlacementResolver _resolvePlacement;
private readonly Dictionary<uint, PendingPlacement> _pending = new();
internal RemoteTeleportController(
PhysicsEngine physics,
LiveEntityRuntime liveEntities,
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder,
Func<Vector3, uint, Vector3> cellLocalForSeed,
Action<WorldEntity, PhysicsBody, uint> syncResolvedShadow,
Action<uint, ushort, bool> completeAuthoritativePlacement,
Action<uint, ushort> beginAuthoritativePlacement,
PlacementResolver? resolvePlacement = null)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_getSetupCylinder = getSetupCylinder
?? throw new ArgumentNullException(nameof(getSetupCylinder));
_cellLocalForSeed = cellLocalForSeed
?? throw new ArgumentNullException(nameof(cellLocalForSeed));
_syncResolvedShadow = syncResolvedShadow
?? throw new ArgumentNullException(nameof(syncResolvedShadow));
_completeAuthoritativePlacement = completeAuthoritativePlacement
?? throw new ArgumentNullException(nameof(completeAuthoritativePlacement));
_beginAuthoritativePlacement = beginAuthoritativePlacement
?? throw new ArgumentNullException(nameof(beginAuthoritativePlacement));
_resolvePlacement = resolvePlacement ?? ResolvePlacement;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
internal readonly record struct Result(
bool Applied,
bool ContactResolved,
Vector3 Position,
uint CellId,
Quaternion Orientation);
private readonly record struct PendingPlacement(
ILiveEntityRemotePlacementRuntime Remote,
PhysicsBody Body,
WorldEntity Entity,
Vector3 RequestedWorldPosition,
uint RequestedCellId,
Quaternion RequestedOrientation,
double GameTime,
ushort Generation,
ushort PositionSequence,
bool WasInContact,
bool WasOnWalkable,
RollbackPlacement Rollback);
private readonly record struct RollbackPlacement(
Vector3 Position,
uint CellId,
Vector3 CellLocalPosition,
Quaternion Orientation,
TransientStateFlags TransientState,
Plane ContactPlane,
bool ContactPlaneValid,
uint ContactPlaneCellId,
bool ContactPlaneIsWater,
double LastUpdateTime,
bool Airborne,
Vector3 LastServerPosition,
double LastServerPositionTime,
Vector3 LastShadowSyncPosition);
internal Result TryApply(
ILiveEntityRemotePlacementRuntime remote,
WorldEntity entity,
Vector3 requestedWorldPosition,
uint requestedCellId,
Vector3 requestedCellLocalPosition,
Quaternion requestedOrientation,
double gameTime,
bool destinationProjectionVisible,
ushort generation,
ushort positionSequence)
{
ArgumentNullException.ThrowIfNull(remote);
ArgumentNullException.ThrowIfNull(entity);
if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord)
|| liveRecord.Generation != generation
|| liveRecord.PhysicsBody is null
|| !ReferenceEquals(liveRecord.PhysicsBody, remote.Body))
{
throw new InvalidOperationException(
$"Remote placement for 0x{entity.ServerGuid:X8} must use its incarnation's canonical physics body.");
}
bool wasInContact = liveRecord.PhysicsBody.InContact;
bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable;
RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody);
if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior)
&& prior.Generation == generation)
{
wasInContact = prior.WasInContact;
wasOnWalkable = prior.WasOnWalkable;
rollback = prior.Rollback;
}
PendingPlacement request = new(
remote,
liveRecord.PhysicsBody,
entity,
requestedWorldPosition,
requestedCellId,
requestedOrientation,
gameTime,
generation,
positionSequence,
wasInContact,
wasOnWalkable,
rollback);
if (!destinationProjectionVisible)
{
ParkPending(request, requestedCellLocalPosition);
return new Result(
true,
false,
requestedWorldPosition,
requestedCellId,
requestedOrientation);
}
ResolveResult placement = Resolve(request);
if (!placement.Ok)
{
_pending.Remove(entity.ServerGuid);
RollBackDeferredPlacement(request);
return new Result(
false,
false,
request.Body.Position,
remote.CellId,
request.Body.Orientation);
}
_pending.Remove(entity.ServerGuid);
return CommitResolved(request, placement);
}
internal void Forget(uint serverGuid)
{
_pending.Remove(serverGuid);
}
internal void Clear()
{
_pending.Clear();
}
internal bool HasPending(uint serverGuid) => _pending.ContainsKey(serverGuid);
/// <summary>
/// Transfers any older deferred shadow restoration before the accepted
/// destination is rebucketed and can publish a visibility edge.
/// </summary>
internal void BeginPlacement(uint serverGuid, ushort generation) =>
_beginAuthoritativePlacement(serverGuid, generation);
public void Dispose()
{
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
Clear();
}
private ResolveResult Resolve(PendingPlacement request)
{
var (radius, height) = _getSetupCylinder(
request.Entity.ServerGuid,
request.Entity);
if (radius < 0.05f)
{
radius = 0.48f;
height = 1.835f;
}
return _resolvePlacement(
request.RequestedWorldPosition,
request.RequestedCellId,
radius,
height,
IsPlayerGuid(request.Entity.ServerGuid)
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
: ObjectInfoState.EdgeSlide,
request.Entity.Id);
}
private ResolveResult ResolvePlacement(
Vector3 position,
uint cellId,
float radius,
float height,
ObjectInfoState moverFlags,
uint movingEntityId) =>
_physics.ResolvePlacement(
position,
cellId,
radius,
height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
moverFlags: moverFlags,
movingEntityId: movingEntityId);
private Result CommitResolved(PendingPlacement request, ResolveResult placement)
{
RemoteTeleportPlacement.Apply(
request.Remote,
request.Body,
placement,
_cellLocalForSeed(placement.Position, placement.CellId),
request.RequestedOrientation,
request.GameTime,
request.WasInContact,
request.WasOnWalkable);
if (request.Remote.CellId != placement.CellId)
request.Remote.CellId = placement.CellId;
request.Remote.LastServerPosition = placement.Position;
request.Remote.LastServerPositionTime = request.GameTime;
request.Remote.LastShadowSyncPosition = Vector3.Zero;
request.Entity.SetPosition(request.Body.Position);
request.Entity.ParentCellId = placement.CellId;
request.Entity.Rotation = request.Body.Orientation;
if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record))
{
bool deferShadowRestore =
record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|| !record.IsSpatiallyVisible;
_completeAuthoritativePlacement(
request.Entity.ServerGuid,
request.Generation,
deferShadowRestore);
if (!deferShadowRestore)
{
request.Remote.LastShadowSyncPosition = request.Body.Position;
_syncResolvedShadow(request.Entity, request.Body, placement.CellId);
}
}
return new Result(
true,
true,
request.Body.Position,
placement.CellId,
request.Body.Orientation);
}
private void ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition)
{
request.Body.Orientation = request.RequestedOrientation;
request.Body.SnapToCell(
request.RequestedCellId,
request.RequestedWorldPosition,
requestedCellLocalPosition);
request.Body.LastUpdateTime = request.GameTime;
request.Body.ContactPlaneValid = false;
request.Body.ContactPlaneCellId = 0u;
request.Body.ContactPlaneIsWater = false;
PhysicsObjUpdate.ApplySetPositionContact(
request.Body,
inContact: false,
onWalkable: false);
request.Remote.Airborne = true;
if (request.Remote.CellId != request.RequestedCellId)
request.Remote.CellId = request.RequestedCellId;
request.Remote.LastServerPosition = request.RequestedWorldPosition;
request.Remote.LastServerPositionTime = request.GameTime;
request.Remote.LastShadowSyncPosition = Vector3.Zero;
request.Entity.SetPosition(request.RequestedWorldPosition);
request.Entity.ParentCellId = request.RequestedCellId;
request.Entity.Rotation = request.RequestedOrientation;
_pending[request.Entity.ServerGuid] = request;
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (!visible)
return;
if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending))
{
if (record.WorldEntity is null
|| !ReferenceEquals(record.WorldEntity, pending.Entity)
|| record.Generation != pending.Generation)
{
_pending.Remove(record.ServerGuid);
return;
}
if (record.RemoteMotionRuntime is not ILiveEntityRemotePlacementRuntime currentRemote
|| record.PhysicsBody is null
|| !ReferenceEquals(record.PhysicsBody, pending.Body)
|| !ReferenceEquals(currentRemote.Body, record.PhysicsBody))
{
_pending.Remove(record.ServerGuid);
if (record.RemoteMotionRuntime is not null
&& (record.PhysicsBody is null
|| !ReferenceEquals(record.RemoteMotionRuntime.Body, record.PhysicsBody)))
{
_liveEntities.ClearRemoteMotionRuntime(record.ServerGuid);
}
RollBackDeferredPlacement(pending);
return;
}
if (!ReferenceEquals(currentRemote, pending.Remote))
{
pending = pending with { Remote = currentRemote };
_pending[record.ServerGuid] = pending;
}
if (record.Snapshot.PositionSequence != pending.PositionSequence)
{
// A newer accepted UpdatePosition rebucketed the projection
// before its TryApply call could replace this request. Keep
// the original rollback alive for that synchronous call.
return;
}
_pending.Remove(record.ServerGuid);
ResolveResult placement = Resolve(pending);
if (!placement.Ok)
RollBackDeferredPlacement(pending);
else
CommitResolved(pending, placement);
return;
}
}
private static RollbackPlacement CaptureRollback(
ILiveEntityRemotePlacementRuntime remote,
PhysicsBody body)
{
return new RollbackPlacement(
body.Position,
body.CellPosition.ObjCellId,
body.CellPosition.Frame.Origin,
body.Orientation,
body.TransientState,
body.ContactPlane,
body.ContactPlaneValid,
body.ContactPlaneCellId,
body.ContactPlaneIsWater,
body.LastUpdateTime,
remote.Airborne,
remote.LastServerPosition,
remote.LastServerPositionTime,
remote.LastShadowSyncPosition);
}
private void RollBackDeferredPlacement(PendingPlacement pending)
{
RollbackPlacement rollback = pending.Rollback;
PhysicsBody body = pending.Body;
body.Orientation = rollback.Orientation;
body.SnapToCell(
rollback.CellId,
rollback.Position,
rollback.CellLocalPosition);
body.TransientState = rollback.TransientState;
body.ContactPlane = rollback.ContactPlane;
body.ContactPlaneValid = rollback.ContactPlaneValid;
body.ContactPlaneCellId = rollback.ContactPlaneCellId;
body.ContactPlaneIsWater = rollback.ContactPlaneIsWater;
body.LastUpdateTime = rollback.LastUpdateTime;
body.calc_acceleration();
pending.Remote.Airborne = rollback.Airborne;
pending.Remote.CellId = rollback.CellId;
pending.Remote.LastServerPosition = rollback.LastServerPosition;
pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime;
pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition;
pending.Entity.SetPosition(rollback.Position);
pending.Entity.ParentCellId = rollback.CellId;
pending.Entity.Rotation = rollback.Orientation;
if (rollback.CellId == 0)
_liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid);
else
_liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId);
if (!_liveEntities.TryGetRecord(
pending.Entity.ServerGuid,
out LiveEntityRecord restored)
|| restored.Generation != pending.Generation)
{
return;
}
if (rollback.CellId == 0)
{
_completeAuthoritativePlacement(
pending.Entity.ServerGuid,
pending.Generation,
false);
return;
}
bool deferShadowRestore =
restored.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)
|| !restored.IsSpatiallyVisible;
_completeAuthoritativePlacement(
pending.Entity.ServerGuid,
pending.Generation,
deferShadowRestore);
if (deferShadowRestore)
return;
pending.Remote.LastShadowSyncPosition = Vector3.Zero;
_syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId);
}
private static bool IsPlayerGuid(uint guid) =>
(guid & 0xFF000000u) == 0x50000000u;
}

View file

@ -0,0 +1,39 @@
using AcDream.Core.Physics;
namespace AcDream.App.Physics;
/// <summary>
/// Ordered App seam for retail <c>CPhysicsObj::teleport_hook</c>
/// (<c>0x00514ED0</c>). The action bundle keeps the port independently
/// testable while the concrete movement/interpolation/target owners remain
/// in the composition root.
/// </summary>
public static class RemoteTeleportHook
{
private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu;
public static void Execute(RemoteTeleportHookActions actions)
{
ArgumentNullException.ThrowIfNull(actions.CancelMoveTo);
ArgumentNullException.ThrowIfNull(actions.UnStick);
ArgumentNullException.ThrowIfNull(actions.StopInterpolating);
ArgumentNullException.ThrowIfNull(actions.UnConstrain);
ArgumentNullException.ThrowIfNull(actions.NotifyTeleported);
ArgumentNullException.ThrowIfNull(actions.ReportCollisionEnd);
actions.CancelMoveTo(TeleportCancelContext);
actions.UnStick();
actions.StopInterpolating();
actions.UnConstrain();
actions.NotifyTeleported();
actions.ReportCollisionEnd();
}
}
public sealed record RemoteTeleportHookActions(
Action<WeenieError> CancelMoveTo,
Action UnStick,
Action StopInterpolating,
Action UnConstrain,
Action NotifyTeleported,
Action ReportCollisionEnd);

View file

@ -0,0 +1,77 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Physics;
namespace AcDream.App.Physics;
/// <summary>
/// Commits the placement half of retail <c>CPhysicsObj::MoveOrTeleport</c>
/// Branch A (<c>0x00516330</c>). The caller runs
/// <see cref="RemoteTeleportHook"/> first, then this exact frame/cell snap,
/// before considering contact-driven interpolation.
/// </summary>
internal static class RemoteTeleportPlacement
{
internal static void Apply(
ILiveEntityRemotePlacementRuntime remote,
PhysicsBody body,
ResolveResult placement,
Vector3 cellLocalPosition,
Quaternion orientation,
double gameTime,
bool previousContact,
bool previousOnWalkable)
{
ArgumentNullException.ThrowIfNull(remote);
ArgumentNullException.ThrowIfNull(body);
if (!placement.Ok
|| !IsFinite(placement.Position)
|| !IsFinite(cellLocalPosition)
|| !PositionFrameValidation.IsValid(
placement.CellId,
cellLocalPosition,
orientation)
|| !double.IsFinite(gameTime))
{
throw new ArgumentOutOfRangeException(
nameof(placement),
"A remote teleport placement requires an accepted finite frame, clock, and nonzero cell.");
}
body.Orientation = orientation;
body.SnapToCell(placement.CellId, placement.Position, cellLocalPosition);
body.LastUpdateTime = gameTime;
body.ContactPlaneValid = placement.InContact;
if (placement.InContact)
{
body.ContactPlane = placement.ContactPlane;
body.ContactPlaneCellId = placement.ContactPlaneCellId;
body.ContactPlaneIsWater = placement.ContactPlaneIsWater;
}
else
{
body.ContactPlaneCellId = 0u;
body.ContactPlaneIsWater = false;
}
PhysicsObjUpdate.CommitSetPositionTransition(
body,
placement.InContact,
placement.OnWalkable,
placement.CollisionNormalValid,
placement.CollisionNormal,
previousContact,
previousOnWalkable,
remote.HitGround,
remote.LeaveGround);
remote.Airborne = !body.OnWalkable;
}
private static bool IsFinite(Vector3 value) =>
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z);
}

View file

@ -135,6 +135,19 @@ public sealed class EntityPhysicsHost : IPhysicsObjHost
/// registry.</summary> /// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>
/// A Hidden object remains logically alive but is removed from the cell
/// lookup/interaction surface. Retail's DetectionManager sends
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
/// not ported yet, so the App bridges the same unavailability edge through
/// the existing target fan-out. The non-Ok update tears down watcher
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
/// UnHide re-subscription receives a fresh initial snapshot. The object's
/// own watcher role is deliberately preserved.
/// </summary>
public void NotifyHidden() =>
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail /// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c> /// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then /// (drop this entity's OWN subscription) then

View file

@ -219,6 +219,7 @@ public sealed class EquippedChildRenderController : IDisposable
child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability); child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability);
if (!ApplyParentWorldPose(child.Entity, parentWorld)) if (!ApplyParentWorldPose(child.Entity, parentWorld))
return false; return false;
ApplyParentDrawVisibility(child.Entity, parent);
child.Entity.ParentCellId = parent.ParentCellId; child.Entity.ParentCellId = parent.ParentCellId;
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose); PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
if (parent.ParentCellId is { } parentCellId) if (parent.ParentCellId is { } parentCellId)
@ -317,6 +318,7 @@ public sealed class EquippedChildRenderController : IDisposable
if (entity is null) if (entity is null)
return false; return false;
ApplyParentWorldPose(entity, parentWorld); ApplyParentWorldPose(entity, parentWorld);
ApplyParentDrawVisibility(entity, parentEntity);
entity.ParentCellId = parentCellId; entity.ParentCellId = parentCellId;
entity.ApplyAppearance( entity.ApplyAppearance(
pose.AttachedParts, pose.AttachedParts,
@ -338,6 +340,14 @@ public sealed class EquippedChildRenderController : IDisposable
pose.AttachedParts, pose.AttachedParts,
scale, scale,
entity); entity);
if (_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord)
&& (parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
{
// A child attached while its parent is already Hidden inherits
// the same direct child NoDraw mutation retail applies from
// CPhysicsObj::set_hidden (0x00514C60).
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
}
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose); PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
Console.WriteLine( Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
@ -376,6 +386,18 @@ public sealed class EquippedChildRenderController : IDisposable
return true; return true;
} }
/// <summary>
/// Preserve retail attachment-tree draw inheritance after adapting every
/// child CPhysicsObj into an independent retained draw entry. This does not
/// alter the child's own NoDraw state; parent-first ticking propagates an
/// ancestor's suppression through arbitrarily deep attachment chains.
/// </summary>
internal static void ApplyParentDrawVisibility(WorldEntity child, WorldEntity parent)
{
child.IsAncestorDrawVisible =
parent.IsDrawVisible && parent.IsAncestorDrawVisible;
}
private static bool TryDecomposeWorldPose( private static bool TryDecomposeWorldPose(
Matrix4x4 world, Matrix4x4 world,
out Vector3 position, out Vector3 position,
@ -425,6 +447,20 @@ public sealed class EquippedChildRenderController : IDisposable
return null; return null;
} }
/// <summary>
/// Retail <c>CPhysicsObj::set_hidden</c> walks the direct CHILDLIST and
/// sets/clears each child's NoDraw bit before changing root collision/cell
/// presentation. Descendants are not recursively rewritten here.
/// </summary>
public void SetDirectChildrenNoDraw(uint parentGuid, bool noDraw)
{
foreach (AttachedChild child in _attachedByChild.Values)
{
if (child.ParentGuid == parentGuid)
_liveEntities.SetAttachedChildNoDraw(child.ChildGuid, noDraw);
}
}
private void ResolveRelations(uint childGuid) private void ResolveRelations(uint childGuid)
{ {
Relations.Resolve( Relations.Resolve(

View file

@ -159,6 +159,7 @@ public sealed class GameWindow : IDisposable
// once the injected shared helpers exist as method groups (GetSetupCylinder, // once the injected shared helpers exist as method groups (GetSetupCylinder,
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater; private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map; // Step 7 projectile presentation. The controller owns no identity map;
// each runtime component is stored on the canonical LiveEntityRecord. // each runtime component is stored on the canonical LiveEntityRecord.
private AcDream.App.Physics.ProjectileController? _projectileController; private AcDream.App.Physics.ProjectileController? _projectileController;
@ -460,10 +461,9 @@ public sealed class GameWindow : IDisposable
/// </para> /// </para>
/// </summary> /// </summary>
internal sealed class RemoteMotion : internal sealed class RemoteMotion :
AcDream.App.World.ILiveEntityRemoteMotionRuntime, AcDream.App.World.ILiveEntityRemotePlacementRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
AcDream.App.World.ILiveEntityCanonicalCellConsumer // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
{ {
public AcDream.Core.Physics.PhysicsBody Body; public AcDream.Core.Physics.PhysicsBody Body { get; }
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the /// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
@ -613,6 +613,36 @@ public sealed class GameWindow : IDisposable
} }
} }
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition
{
get => LastServerPos;
set => LastServerPos = value;
}
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime
{
get => LastServerPosTime;
set => LastServerPosTime = value;
}
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition
{
get => LastShadowSyncPos;
set => LastShadowSyncPos = value;
}
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne
{
get => Airborne;
set => Airborne = value;
}
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() =>
Movement.HitGround();
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() =>
Motion.LeaveGround();
/// <summary> /// <summary>
/// K-fix9 (2026-04-26): true while the remote is airborne (jump /// K-fix9 (2026-04-26): true while the remote is airborne (jump
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with /// arc in flight). Set when a 0xF74E VectorUpdate arrives with
@ -857,6 +887,7 @@ public sealed class GameWindow : IDisposable
// from the animation pipeline flip the matching LightSource.IsLit. // from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink; private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights; private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
private AcDream.App.World.LiveEntityPresentationController? _liveEntityPresentation;
// #188 — TransparentPartHook fires from the animation pipeline drive // #188 — TransparentPartHook fires from the animation pipeline drive
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it // a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
@ -2173,7 +2204,8 @@ public sealed class GameWindow : IDisposable
playerCellId: () => _playerController?.CellId ?? 0u, playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId, selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI); uiLocked: () => _persistedGameplay.LockUI,
playerEntities: _entitiesByServerGuid);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
_retailChatVm = retailChatVm; _retailChatVm = retailChatVm;
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
@ -2444,7 +2476,6 @@ public sealed class GameWindow : IDisposable
{ {
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
}; };
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController( _liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
_liveEntities, _liveEntities,
_effectPoses, _effectPoses,
@ -2479,9 +2510,34 @@ public sealed class GameWindow : IDisposable
_entityEffects = entityEffects; _entityEffects = entityEffects;
entityEffects.DiagnosticSink = message => entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}"); Console.Error.WriteLine($"vfx: {message}");
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
_liveEntities,
_physicsEngine.ShadowObjects,
entityEffects.PlayTyped,
_equippedChildRenderer.SetDirectChildrenNoDraw,
ClearTargetForHiddenEntity,
() => (_liveCenterX, _liveCenterY));
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
_physicsEngine,
_liveEntities,
GetSetupCylinder,
CellLocalForSeed,
(entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id,
remote,
_liveCenterX,
_liveCenterY,
cellId),
(guid, generation, deferShadowRestore) =>
_liveEntityPresentation.CompleteAuthoritativePlacement(
guid,
generation,
deferShadowRestore),
(guid, generation) =>
_liveEntityPresentation.BeginAuthoritativePlacement(guid, generation));
_equippedChildRenderer.EntityReady += guid => _equippedChildRenderer.EntityReady += guid =>
{ {
entityEffects.OnLiveEntityReady(guid); CompleteLiveEntityReady(guid);
}; };
_equippedChildRenderer.ProjectionPoseReady += guid => _equippedChildRenderer.ProjectionPoseReady += guid =>
_liveEntityLights.OnAttachedPoseReady(guid); _liveEntityLights.OnAttachedPoseReady(guid);
@ -2704,16 +2760,33 @@ public sealed class GameWindow : IDisposable
_equippedChildRenderer?.Clear(); _equippedChildRenderer?.Clear();
Objects.Clear(); Objects.Clear();
_selection.Reset(); _selection.Reset();
_pendingPostArrivalAction = null;
try try
{ {
_liveEntities?.Clear(); _liveEntities?.Clear();
} }
finally finally
{ {
// F754/F755 can precede CreateObject, so the mixed pending FIFO try
// may contain owners with no LiveEntityRecord to tear down. {
_entityEffects?.ClearNetworkState(); // A pending teleport is operational state outside the live
_animationHookFrames?.Clear(); // record. Clear it independently even if a resource callback
// failed while the canonical runtime was draining.
_remoteTeleportController?.Clear();
}
finally
{
try
{
// F754/F755 can precede CreateObject, so the mixed pending FIFO
// may contain owners with no LiveEntityRecord to tear down.
_entityEffects?.ClearNetworkState();
}
finally
{
_animationHookFrames?.Clear();
}
}
} }
} }
@ -4096,7 +4169,7 @@ public sealed class GameWindow : IDisposable
// Renderer, script owner, optional animation owner, collision body, // Renderer, script owner, optional animation owner, collision body,
// and effect profile are now all installed. Only at this boundary may // and effect profile are now all installed. Only at this boundary may
// the mixed pre-Create F754/F755 FIFO replay against the local ID. // the mixed pre-Create F754/F755 FIFO replay against the local ID.
_entityEffects?.OnLiveEntityReady(spawn.Guid); CompleteLiveEntityReady(spawn.Guid);
// Dump a summary periodically so we can see drop breakdowns without // Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown. // waiting for a graceful shutdown.
@ -4468,7 +4541,9 @@ public sealed class GameWindow : IDisposable
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature) if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature; flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
uint state = spawn.PhysicsState ?? 0u; uint state = _liveEntities?.TryGetRecord(spawn.Guid, out LiveEntityRecord liveRecord) == true
? (uint)liveRecord.FinalPhysicsState
: spawn.PhysicsState ?? 0u;
_physicsEngine.ShadowObjects.RegisterMultiPart( _physicsEngine.ShadowObjects.RegisterMultiPart(
entityId: entity.Id, entityId: entity.Id,
@ -4596,6 +4671,13 @@ public sealed class GameWindow : IDisposable
uint cellId, uint cellId,
bool force = false) bool force = false)
{ {
if (_liveEntities?.IsHidden(_playerServerGuid) == true)
{
_physicsEngine.ShadowObjects.Suspend(playerEntity.Id);
_lastLocalPlayerShadow = null;
return;
}
if (!force if (!force
&& _lastLocalPlayerShadow is { } last && _lastLocalPlayerShadow is { } last
&& last.CellId == cellId && last.CellId == cellId
@ -4836,17 +4918,20 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id) private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
{ {
// CObjCell::hide_object removes Hidden objects from the lookup surface
// used to establish new target/voyeur relationships. Existing
// subscriptions are cleared by LiveEntityPresentationController.
if (!_visibleEntitiesByServerGuid.ContainsKey(id))
return null;
if (_physicsHosts.TryGetValue(id, out var existing)) if (_physicsHosts.TryGetValue(id, out var existing))
return existing; return existing;
if (!_entitiesByServerGuid.ContainsKey(id))
return null;
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
var minimal = new EntityPhysicsHost( var minimal = new EntityPhysicsHost(
id, id,
getPosition: () => new AcDream.Core.Physics.Position( getPosition: () => new AcDream.Core.Physics.Position(
0u, 0u,
_entitiesByServerGuid.TryGetValue(id, out var e) _visibleEntitiesByServerGuid.TryGetValue(id, out var e)
? e.Position : System.Numerics.Vector3.Zero, ? e.Position : System.Numerics.Vector3.Zero,
System.Numerics.Quaternion.Identity), System.Numerics.Quaternion.Identity),
getVelocity: () => System.Numerics.Vector3.Zero, // static target getVelocity: () => System.Numerics.Vector3.Zero, // static target
@ -4914,20 +4999,63 @@ public sealed class GameWindow : IDisposable
{ {
if (host is null) if (host is null)
return; return;
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var tgtEnt)) if (_liveEntities is not { } liveEntities
|| !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
return; return;
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt); var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
host.PositionManager.StickTo(targetGuid, radius, height); host.PositionManager.StickTo(targetGuid, radius, height);
} }
/// <summary>
/// Completes retail object construction in order: register the effect
/// owner, apply constructor/PhysicsDesc state transitions, then replay
/// SmartBox's queued F754/F755 blobs.
/// </summary>
private void CompleteLiveEntityReady(uint serverGuid)
{
if (_entityEffects?.PrepareLiveEntityOwner(serverGuid) != true)
return;
_liveEntityPresentation?.OnLiveEntityReady(serverGuid);
_entityEffects.ReplayPendingForLiveEntity(serverGuid);
}
private void ClearTargetForHiddenEntity(uint serverGuid)
{
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
&& pendingGuid == serverGuid)
_pendingPostArrivalAction = null;
if (_selection.SelectedObjectId == serverGuid)
{
_selection.Clear(
AcDream.Core.Selection.SelectionChangeSource.System,
AcDream.Core.Selection.SelectionChangeReason.Cleared);
}
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
hiddenEntityHost.NotifyHidden();
}
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record) private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
{ {
_entityEffects?.OnLiveEntityUnregistered(record); uint serverGuid = record.ServerGuid;
var cleanups = new List<Action>
{
() => _liveEntityPresentation?.Forget(record),
() => _entityEffects?.OnLiveEntityUnregistered(record),
() => _remoteTeleportController?.Forget(serverGuid),
};
if (_pendingPostArrivalAction is { Guid: var pendingGuid }
&& pendingGuid == serverGuid)
_pendingPostArrivalAction = null;
if (record.WorldEntity is not { } existingEntity) if (record.WorldEntity is not { } existingEntity)
{
AcDream.App.World.LiveEntityTeardown.Run(cleanups);
return; return;
}
uint serverGuid = record.ServerGuid;
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains // R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
// independently (r3-port-plan §4): the manager's (each pending // independently (r3-port-plan §4): the manager's (each pending
// animation fires MotionDone(success:false) → the bound interp pops // animation fires MotionDone(success:false) → the bound interp pops
@ -4935,9 +5063,9 @@ public sealed class GameWindow : IDisposable
// MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade // MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade
// relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30). // relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30).
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone)) if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
aeGone.Sequencer?.Manager.HandleExitWorld(); cleanups.Add(() => aeGone.Sequencer?.Manager.HandleExitWorld());
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone)) if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
rmGone.Movement.HandleExitWorld(); cleanups.Add(rmGone.Movement.HandleExitWorld);
// R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
// entity that it left the world (TargetManager::NotifyVoyeurOfEvent // entity that it left the world (TargetManager::NotifyVoyeurOfEvent
// ExitWorld) — a watcher moving-to/stuck-to this entity drops the // ExitWorld) — a watcher moving-to/stuck-to this entity drops the
@ -4956,31 +5084,35 @@ public sealed class GameWindow : IDisposable
// (ExitWorld) tells the entities watching IT. Without the first // (ExitWorld) tells the entities watching IT. Without the first
// two, a despawning attacker leaves a dead voyeur entry on its // two, a despawning attacker leaves a dead voyeur entry on its
// target until send-failure pruning. // target until send-failure pruning.
ephGone.PositionManager.UnStick(); cleanups.Add(ephGone.PositionManager.UnStick);
ephGone.ClearTarget(); cleanups.Add(ephGone.ClearTarget);
ephGone.NotifyExitWorld(); cleanups.Add(ephGone.NotifyExitWorld);
} }
_physicsHosts.Remove(serverGuid); cleanups.Add(() => _physicsHosts.Remove(serverGuid));
_animatedEntities.Remove(existingEntity.Id); cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id));
_classificationCache.InvalidateEntity(existingEntity.Id); cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id));
// Dead-reckon state is keyed by SERVER guid (not local id) so we // Dead-reckon state is keyed by SERVER guid (not local id) so we
// clear using the same guid the next spawn/update would use. // clear using the same guid the next spawn/update would use.
_remoteDeadReckon.Remove(serverGuid); cleanups.Add(() => _remoteDeadReckon.Remove(serverGuid));
_remoteLastMove.Remove(serverGuid); cleanups.Add(() => _remoteLastMove.Remove(serverGuid));
if (_selection.SelectedObjectId == serverGuid) cleanups.Add(() =>
{ {
_selection.Clear( if (_selection.SelectedObjectId == serverGuid)
AcDream.Core.Selection.SelectionChangeSource.System, {
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); _selection.Clear(
} AcDream.Core.Selection.SelectionChangeSource.System,
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
}
});
_translucencyFades.ClearEntity(existingEntity.Id); // #188 cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
LeaveWorldLiveEntityRuntimeComponents(record); cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record));
// Logical teardown ends the retained shadow payload too. The weaker // Logical teardown ends the retained shadow payload too. The weaker
// pickup/parent path stops after Suspend so re-entry can restore it. // pickup/parent path stops after Suspend so re-entry can restore it.
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id); cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id));
_liveEntityLights?.Forget(existingEntity.Id); cleanups.Add(() => _liveEntityLights?.Forget(existingEntity.Id));
AcDream.App.World.LiveEntityTeardown.Run(cleanups);
} }
/// <summary> /// <summary>
@ -5075,7 +5207,8 @@ public sealed class GameWindow : IDisposable
// MoveToPosition at the wire origin (§2f). // MoveToPosition at the wire origin (§2f).
if (update.MotionState.MovementType == 6 if (update.MotionState.MovementType == 6
&& path.TargetGuid is { } tgtGuid && path.TargetGuid is { } tgtGuid
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt)) && _liveEntities is { } liveMoveEntities
&& liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
{ {
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject; ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
ms.ObjectId = tgtGuid; ms.ObjectId = tgtGuid;
@ -5115,7 +5248,8 @@ public sealed class GameWindow : IDisposable
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp }; var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
if (update.MotionState.MovementType == 8 if (update.MotionState.MovementType == 8
&& turnPath.TargetGuid is { } turnTgt && turnPath.TargetGuid is { } turnTgt
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt)) && _liveEntities is { } liveTurnEntities
&& liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
{ {
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject; ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
ms.ObjectId = turnTgt; ms.ObjectId = turnTgt;
@ -5761,15 +5895,25 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed) private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
{ {
if (!_liveEntities!.TryApplyState(parsed, out _)) return; if (!_liveEntities!.TryApplyState(parsed, out _, out _)) return;
if (!_liveEntities.TryGetRecord(parsed.Guid, out LiveEntityRecord record))
return;
// Retail set_state order: Lighting, NoDraw, then Hidden. The live
// runtime already committed the raw/final bits and draw visibility;
// apply the ordered owners before updating motion/collision consumers.
_liveEntityLights?.OnStateChanged(parsed.Guid);
_liveEntityPresentation?.OnStateAccepted(parsed.Guid);
_projectileController?.ApplyAuthoritativeState( _projectileController?.ApplyAuthoritativeState(
parsed.Guid, parsed.Guid,
(AcDream.Core.Physics.PhysicsStateFlags)parsed.PhysicsState, record.FinalPhysicsState,
_physicsScriptGameTime, _physicsScriptGameTime,
_liveCenterX, _liveCenterX,
_liveCenterY); _liveCenterY);
_liveEntityLights?.OnStateChanged(parsed.Guid); if (parsed.Guid == _playerServerGuid)
_playerController?.ApplyPhysicsState(record.FinalPhysicsState);
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
@ -5782,11 +5926,9 @@ public sealed class GameWindow : IDisposable
// ACE flipped the ETHEREAL bit. // ACE flipped the ETHEREAL bit.
uint registryKey = entity.Id; uint registryKey = entity.Id;
_physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant( Console.WriteLine(System.FormattableString.Invariant(
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}")); $"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} raw=0x{parsed.PhysicsState:X8} final=0x{(uint)record.FinalPhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
} }
private void ApplyServerControlledVelocityCycle( private void ApplyServerControlledVelocityCycle(
@ -5995,6 +6137,14 @@ public sealed class GameWindow : IDisposable
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
DumpMovementTruthServerEcho(update, worldPos); DumpMovementTruthServerEcho(update, worldPos);
bool remoteHardTeleport = update.Guid != _playerServerGuid
&& timestamps.TeleportHookRequired;
bool remotePlacementRequired = update.Guid != _playerServerGuid
&& (remoteHardTeleport
|| _remoteTeleportController?.HasPending(update.Guid) == true);
if (remoteHardTeleport)
RunRemoteTeleportHook(update.Guid, entity.Id);
// Missiles reconcile the same predicted PhysicsBody in place. The // Missiles reconcile the same predicted PhysicsBody in place. The
// timestamp gate above already rejected stale corrections; returning // timestamp gate above already rejected stale corrections; returning
// here prevents the generic remote locomotion path from allocating a // here prevents the generic remote locomotion path from allocating a
@ -6013,6 +6163,13 @@ public sealed class GameWindow : IDisposable
_liveCenterY) == true) _liveCenterY) == true)
return; return;
if (remotePlacementRequired)
{
_remoteTeleportController!.BeginPlacement(
update.Guid,
acceptedSpawn.InstanceSequence);
}
// Capture the pre-update render position for the soft-snap residual // Capture the pre-update render position for the soft-snap residual
// calculation below. Assign entity.Position to the server truth up // calculation below. Assign entity.Position to the server truth up
// front; if we then compute a snap residual, we restore the rendered // front; if we then compute a snap residual, we restore the rendered
@ -6096,6 +6253,50 @@ public sealed class GameWindow : IDisposable
rmState.Body.Position = worldPos; rmState.Body.Position = worldPos;
} }
// Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a
// fresh TELEPORT_TS, or the first placement of a cell-less body,
// runs teleport_hook and SetPosition(0x1012) BEFORE the contact
// test. In particular, an airborne UP cannot veto or undo this
// authoritative destination. Do not pre-clear velocity or invent
// grounded flags here: retail SetPosition derives contact from its
// transition, while MoveOrTeleport does not consume arg5/arg6.
if (remotePlacementRequired)
{
double teleportTime =
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
bool projectionVisible = _liveEntities.TryGetRecord(
update.Guid,
out LiveEntityRecord teleportRecord)
&& teleportRecord.IsSpatiallyVisible;
var placement = _remoteTeleportController!.TryApply(
rmState,
entity,
worldPos,
p.LandblockId,
new System.Numerics.Vector3(
p.PositionX,
p.PositionY,
p.PositionZ),
rot,
teleportTime,
projectionVisible,
acceptedSpawn.InstanceSequence,
acceptedSpawn.PositionSequence);
if (!placement.Applied)
{
entity.SetPosition(rmState.Body.Position);
entity.ParentCellId = rmState.CellId;
entity.Rotation = rmState.Body.Orientation;
if (rmState.CellId != 0)
_liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId);
return;
}
entity.SetPosition(rmState.Body.Position);
entity.Rotation = rmState.Body.Orientation;
return;
}
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport // player remotes. Mirrors CPhysicsObj::MoveOrTeleport
// (acclient @ 0x00516330) — airborne no-op, far-snap, near // (acclient @ 0x00516330) — airborne no-op, far-snap, near
@ -6284,7 +6485,7 @@ public sealed class GameWindow : IDisposable
// DR tick) and for no-Sequencer players. rmState.CellId is the server // DR tick) and for no-Sequencer players. rmState.CellId is the server
// cell adopted above (:5735). The per-tick loop keeps it current // cell adopted above (:5735). The per-tick loop keeps it current
// between UPs (movement-gated). // between UPs (movement-gated).
if (rmState.CellId != 0) if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid))
_remotePhysicsUpdater.SyncRemoteShadowToBody( _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id, rmState, _liveCenterX, _liveCenterY); entity.Id, rmState, _liveCenterX, _liveCenterY);
entity.SetPosition(rmState.Body.Position); entity.SetPosition(rmState.Body.Position);
@ -6514,7 +6715,7 @@ public sealed class GameWindow : IDisposable
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which // Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
// the per-tick loop skips). The per-tick loop keeps it current between // the per-tick loop skips). The per-tick loop keeps it current between
// UPs, movement-gated. rmState.CellId is the server cell adopted above. // UPs, movement-gated. rmState.CellId is the server cell adopted above.
if (rmState.CellId != 0) if (rmState.CellId != 0 && !_liveEntities.IsHidden(update.Guid))
_remotePhysicsUpdater.SyncRemoteShadowToBody( _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id, rmState, _liveCenterX, _liveCenterY); entity.Id, rmState, _liveCenterX, _liveCenterY);
@ -8777,7 +8978,10 @@ public sealed class GameWindow : IDisposable
// (which runs after this block). Replaces the AP-79 player poll. // (which runs after this block). Replaces the AP-79 player poll.
_playerHost?.HandleTargetting(); _playerHost?.HandleTargetting();
var result = _playerController.Update((float)dt, input); bool localPlayerHidden = _liveEntities?.IsHidden(_playerServerGuid) == true;
var result = localPlayerHidden
? _playerController.TickHidden((float)dt)
: _playerController.Update((float)dt, input);
// Update the player entity's position + rotation so it renders at // Update the player entity's position + rotation so it renders at
// the physics-resolved location each frame. // the physics-resolved location each frame.
@ -8787,7 +8991,8 @@ public sealed class GameWindow : IDisposable
pe.ParentCellId = result.CellId; pe.ParentCellId = result.CellId;
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
SyncLocalPlayerShadow(pe, result.CellId); if (!localPlayerHidden)
SyncLocalPlayerShadow(pe, result.CellId);
// Move the player entity to its current landblock in GpuWorldState // Move the player entity to its current landblock in GpuWorldState
// so it doesn't get frustum-culled when the player walks away from // so it doesn't get frustum-culled when the player walks away from
@ -8858,7 +9063,7 @@ public sealed class GameWindow : IDisposable
trackedTargetPoint: trackedCombatTarget); trackedTargetPoint: trackedCombatTarget);
// Send outbound movement messages to the live server. // Send outbound movement messages to the live server.
if (_liveSession is not null) if (_liveSession is not null && !localPlayerHidden)
{ {
// Convert world position back to AC wire coordinates. // Convert world position back to AC wire coordinates.
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units. // World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
@ -9274,6 +9479,14 @@ public sealed class GameWindow : IDisposable
// Re-publish without advancing so an extra render between updates still // Re-publish without advancing so an extra render between updates still
// retains retail's animation-hook versus object-hook phase barrier. // retains retail's animation-hook versus object-hook phase barrier.
_scriptRunner?.PublishTime(_physicsScriptGameTime); _scriptRunner?.PublishTime(_physicsScriptGameTime);
if (_liveEntities is { } liveEntities)
{
_remotePhysicsUpdater.TickHiddenEntities(
liveEntities,
_playerServerGuid,
(float)deltaSeconds,
entity => _effectPoses.UpdateRoot(entity));
}
_projectileController?.Tick( _projectileController?.Tick(
_physicsScriptGameTime, _physicsScriptGameTime,
_liveCenterX, _liveCenterX,
@ -10386,6 +10599,22 @@ public sealed class GameWindow : IDisposable
} }
} }
private void RunRemoteTeleportHook(uint serverGuid, uint localEntityId)
{
_remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote);
EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered)
? registered as EntityPhysicsHost
: remote?.Host;
AcDream.App.Physics.RemoteTeleportHook.Execute(
new AcDream.App.Physics.RemoteTeleportHookActions(
CancelMoveTo: error => remote?.Movement.CancelMoveTo(error),
UnStick: () => host?.PositionManager.UnStick(),
StopInterpolating: () => remote?.Interp.Clear(),
UnConstrain: () => host?.PositionManager.UnConstrain(),
NotifyTeleported: () => host?.NotifyTeleported(),
ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)));
}
/// <summary> /// <summary>
/// Phase 6.4: advance every animated entity's frame counter by /// Phase 6.4: advance every animated entity's frame counter by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's /// <paramref name="dt"/> * Framerate, wrapping around the cycle's
@ -10433,6 +10662,21 @@ public sealed class GameWindow : IDisposable
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false) && _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
continue; continue;
// Retail UpdatePositionInternal keeps a narrow Hidden path alive:
// no PartArray animation and no physics integration, but
// PositionManager::adjust_offset plus the manager tail still run.
// Scripts and particles tick in their shared passes below.
if (serverGuid != 0 && _liveEntities?.IsHidden(serverGuid) == true)
{
// Hidden suppresses the PartArray/mesh, not the effect owner.
// Remote PositionManager composition already ran in the
// unconditional live-entity pass; publish again for the local
// player and for retained objects without remote motion.
// Frozen part-local poses remain unchanged until UnHide.
_effectPoses.UpdateRoot(ae.Entity);
continue;
}
// ── Dead-reckoning: smooth position between UpdatePosition bursts. // ── Dead-reckoning: smooth position between UpdatePosition bursts.
// The server broadcasts UpdatePosition at ~5-10Hz for distant // The server broadcasts UpdatePosition at ~5-10Hz for distant
// entities; without integration, remote chars jitter-hop between // entities; without integration, remote chars jitter-hop between
@ -13570,6 +13814,8 @@ public sealed class GameWindow : IDisposable
} }
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine); _playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord playerRecord) == true)
_playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// R4-V5: the local player's verbatim MoveToManager — same seam // R4-V5: the local player's verbatim MoveToManager — same seam
// wiring shape as EnsureRemoteMotionBindings, with three // wiring shape as EnsureRemoteMotionBindings, with three
@ -14192,6 +14438,9 @@ public sealed class GameWindow : IDisposable
{ {
_liveEntityLights?.Dispose(); _liveEntityLights?.Dispose();
_liveEntityLights = null; _liveEntityLights = null;
_liveEntityPresentation?.Dispose();
_remoteTeleportController?.Dispose();
_remoteTeleportController = null;
_entityEffects?.ClearNetworkState(); _entityEffects?.ClearNetworkState();
_animationHookFrames?.Clear(); _animationHookFrames?.Clear();
_effectPoses.Clear(); _effectPoses.Clear();

View file

@ -103,6 +103,19 @@ public sealed class EntityEffectController : IAnimationHookSink
/// original mixed F754/F755 order. /// original mixed F754/F755 order.
/// </summary> /// </summary>
public bool OnLiveEntityReady(uint serverGuid) public bool OnLiveEntityReady(uint serverGuid)
{
if (!PrepareLiveEntityOwner(serverGuid))
return false;
ReplayPendingForLiveEntity(serverGuid);
return true;
}
/// <summary>
/// Registers the local effect owner without replaying pre-create packets.
/// Production uses this narrow barrier so constructor/set_description
/// state effects can run before SmartBox replays queued network blobs.
/// </summary>
public bool PrepareLiveEntityOwner(uint serverGuid)
{ {
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity || record.WorldEntity is not { } entity
@ -116,7 +129,15 @@ public sealed class EntityEffectController : IAnimationHookSink
_profilesByLocalId[entity.Id] = profile; _profilesByLocalId[entity.Id] = profile;
_runner.SetOwnerAnchor(entity.Id, entity.Position); _runner.SetOwnerAnchor(entity.Id, entity.Position);
_ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid); _ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid);
TryReplayPending(serverGuid, entity.Id); return true;
}
/// <summary>Replays the mixed F754/F755 FIFO after full object construction.</summary>
public bool ReplayPendingForLiveEntity(uint serverGuid)
{
if (!TryGetReadyLocalId(serverGuid, out uint localId))
return false;
TryReplayPending(serverGuid, localId);
return true; return true;
} }

View file

@ -701,6 +701,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
foreach (var animatedId in animatedEntityIds) foreach (var animatedId in animatedEntityIds)
{ {
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue; if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
// Phase A8: EntitySet partition for indoor/outdoor split passes. // Phase A8: EntitySet partition for indoor/outdoor split passes.
if (!EntityMatchesSet(entity, set)) continue; if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue; if (entity.MeshRefs.Count == 0) continue;
@ -722,6 +723,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
foreach (var entity in entry.Entities) foreach (var entity in entry.Entities)
{ {
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
// Phase A8: EntitySet partition for indoor/outdoor split passes. // Phase A8: EntitySet partition for indoor/outdoor split passes.
if (!EntityMatchesSet(entity, set)) continue; if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue; if (entity.MeshRefs.Count == 0) continue;

View file

@ -88,6 +88,8 @@ public sealed class GpuWorldState
// list, so rebuilding it replaces the reference atomically. // list, so rebuilding it replaces the reference atomically.
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>(); private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
private readonly HashSet<uint> _visibleLiveGuids = new(); private readonly HashSet<uint> _visibleLiveGuids = new();
private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new();
private bool _dispatchingVisibilityTransitions;
public IReadOnlyList<WorldEntity> Entities => _flatEntities; public IReadOnlyList<WorldEntity> Entities => _flatEntities;
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys; public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
@ -282,6 +284,10 @@ public sealed class GpuWorldState
// and it couldn't reach a pending entity). Re-appending routes the entity // and it couldn't reach a pending entity). Re-appending routes the entity
// to _loaded (drawn) when its landblock is loaded, or back to pending to // to _loaded (drawn) when its landblock is loaded, or back to pending to
// await AddLandblock otherwise. // await AddLandblock otherwise.
// Remove + place is one spatial transaction. Rebuilding between the
// two operations exposes a false/true implementation pulse for a
// loaded-to-loaded move and lets reentrant visibility callbacks observe
// a state that never exists at the LiveEntityRuntime boundary.
RemoveEntityFromAllBuckets(entity); RemoveEntityFromAllBuckets(entity);
PlaceLiveEntityProjection(canonical, entity); PlaceLiveEntityProjection(canonical, entity);
} }
@ -307,7 +313,6 @@ public sealed class GpuWorldState
if (j != i) newList.Add(entities[j]); if (j != i) newList.Add(entities[j]);
_loaded[kvp.Key] = new LoadedLandblock( _loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId, kvp.Value.Heightmap, newList); kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
RebuildFlatView();
return; return;
} }
} }
@ -510,6 +515,7 @@ public sealed class GpuWorldState
bucket.Add(entity); bucket.Add(entity);
if (probePersistent) if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)"); EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
RebuildFlatView();
} }
/// <summary> /// <summary>
@ -624,21 +630,48 @@ public sealed class GpuWorldState
_flatEntities _flatEntities
.Where(entity => entity.ServerGuid != 0) .Where(entity => entity.ServerGuid != 0)
.Select(entity => entity.ServerGuid)); .Select(entity => entity.ServerGuid));
foreach (uint guid in _visibleLiveGuids) uint[] becameHidden = _visibleLiveGuids
{ .Where(guid => !nowVisible.Contains(guid))
if (!nowVisible.Contains(guid)) .ToArray();
LiveProjectionVisibilityChanged?.Invoke(guid, false); uint[] becameVisible = nowVisible
} .Where(guid => !_visibleLiveGuids.Contains(guid))
foreach (uint guid in nowVisible) .ToArray();
{
if (!_visibleLiveGuids.Contains(guid)) // Commit the new spatial truth before notifying observers. A
LiveProjectionVisibilityChanged?.Invoke(guid, true); // visibility callback may synchronously rebucket/withdraw the same
} // object (deferred teleport rollback); those nested transitions must
// compare against this committed state rather than the stale prior set.
_visibleLiveGuids.Clear(); _visibleLiveGuids.Clear();
_visibleLiveGuids.UnionWith(nowVisible); _visibleLiveGuids.UnionWith(nowVisible);
foreach (uint guid in becameHidden)
_visibilityTransitions.Enqueue((guid, false));
foreach (uint guid in becameVisible)
_visibilityTransitions.Enqueue((guid, true));
DrainVisibilityTransitions();
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions(); if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
} }
private void DrainVisibilityTransitions()
{
if (_dispatchingVisibilityTransitions)
return;
_dispatchingVisibilityTransitions = true;
try
{
while (_visibilityTransitions.TryDequeue(out var transition))
{
LiveProjectionVisibilityChanged?.Invoke(
transition.ServerGuid,
transition.Visible);
}
}
finally
{
_dispatchingVisibilityTransitions = false;
}
}
// TEMP (#138-B): persistent guids currently present in the drawn flat // TEMP (#138-B): persistent guids currently present in the drawn flat
// view, for EntityVanishProbe transition logging. Strip with the probe. // view, for EntityVanishProbe transition logging. Strip with the probe.
private readonly HashSet<uint> _persistentInFlatProbe = new(); private readonly HashSet<uint> _persistentInFlatProbe = new();

View file

@ -20,6 +20,7 @@ public sealed class RadarSnapshotProvider
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities; private readonly IReadOnlyDictionary<uint, WorldEntity> _worldEntities;
private readonly IReadOnlyDictionary<uint, WorldEntity> _playerEntities;
private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns; private readonly IReadOnlyDictionary<uint, WorldSession.EntitySpawn> _spawns;
private readonly Func<uint> _playerGuid; private readonly Func<uint> _playerGuid;
private readonly Func<float> _playerYawRadians; private readonly Func<float> _playerYawRadians;
@ -39,10 +40,12 @@ public sealed class RadarSnapshotProvider
Func<uint?> selectedGuid, Func<uint?> selectedGuid,
Func<bool> coordinatesOnRadar, Func<bool> coordinatesOnRadar,
Func<bool> uiLocked, Func<bool> uiLocked,
Func<uint, RadarRelationshipTraits>? relationshipFor = null) Func<uint, RadarRelationshipTraits>? relationshipFor = null,
IReadOnlyDictionary<uint, WorldEntity>? playerEntities = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities)); _worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities));
_playerEntities = playerEntities ?? worldEntities;
_spawns = spawns ?? throw new ArgumentNullException(nameof(spawns)); _spawns = spawns ?? throw new ArgumentNullException(nameof(spawns));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians)); _playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians));
@ -57,7 +60,7 @@ public sealed class RadarSnapshotProvider
{ {
bool uiLocked = _uiLocked(); bool uiLocked = _uiLocked();
uint playerGuid = _playerGuid(); uint playerGuid = _playerGuid();
if (playerGuid == 0u || !_worldEntities.TryGetValue(playerGuid, out var playerEntity)) if (playerGuid == 0u || !_playerEntities.TryGetValue(playerGuid, out var playerEntity))
return UiRadarSnapshot.Empty with { UiLocked = uiLocked }; return UiRadarSnapshot.Empty with { UiLocked = uiLocked };
float heading = MoveToMath.HeadingFromYaw(_playerYawRadians()); float heading = MoveToMath.HeadingFromYaw(_playerYawRadians());

View file

@ -319,7 +319,10 @@ public sealed class InboundPhysicsStateController
update.TeleportSequence, update.TeleportSequence,
update.ForcePositionSequence, update.ForcePositionSequence,
isLocalPlayer); isLocalPlayer);
timestamps = Current(gate); timestamps = Current(
gate,
teleportAdvanced: disposition is PositionTimestampDisposition.Apply
&& advancesTeleport);
if (disposition is PositionTimestampDisposition.Rejected) if (disposition is PositionTimestampDisposition.Rejected)
{ {
accepted = MirrorGateTimestamps(old, gate); accepted = MirrorGateTimestamps(old, gate);
@ -438,11 +441,14 @@ public sealed class InboundPhysicsStateController
0, 0,
spawn.InstanceSequence); spawn.InstanceSequence);
private static AcceptedPhysicsTimestamps Current(PhysicsTimestampGate gate) => new( private static AcceptedPhysicsTimestamps Current(
PhysicsTimestampGate gate,
bool teleportAdvanced = false) => new(
gate.InstanceTimestamp, gate.InstanceTimestamp,
gate.ServerControlledMoveTimestamp, gate.ServerControlledMoveTimestamp,
gate.TeleportTimestamp, gate.TeleportTimestamp,
gate.ForcePositionTimestamp); gate.ForcePositionTimestamp,
teleportAdvanced);
private static WorldSession.EntitySpawn MirrorGateTimestamps( private static WorldSession.EntitySpawn MirrorGateTimestamps(
WorldSession.EntitySpawn spawn, WorldSession.EntitySpawn spawn,
@ -622,7 +628,9 @@ public readonly record struct AcceptedPhysicsTimestamps(
ushort Instance, ushort Instance,
ushort ServerControlledMove, ushort ServerControlledMove,
ushort Teleport, ushort Teleport,
ushort ForcePosition); ushort ForcePosition,
bool TeleportAdvanced = false,
bool TeleportHookRequired = false);
public readonly record struct CreateParentUpdate( public readonly record struct CreateParentUpdate(
uint ChildGuid, uint ChildGuid,

View file

@ -0,0 +1,282 @@
using AcDream.App.Physics;
using AcDream.Core.Physics;
namespace AcDream.App.World;
/// <summary>
/// Applies the presentation/collision side effects of accepted retail
/// PhysicsState transitions after the canonical live owner is ready.
/// Logical ownership stays in <see cref="LiveEntityRuntime"/>; Hidden never
/// unregisters scripts, particles, lights, meshes, or the live record.
/// </summary>
/// <remarks>
/// Ports <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>),
/// <c>set_nodraw</c> (<c>0x0050FCA0</c>), and <c>set_hidden</c>
/// (<c>0x00514C60</c>). Typed script ids are retail
/// <c>PS_UnHide=0x75</c> and <c>PS_Hidden=0x76</c> from <c>acclient.h</c>.
/// </remarks>
public sealed class LiveEntityPresentationController : IDisposable
{
public const uint UnHideScriptType = 0x75u;
public const uint HiddenScriptType = 0x76u;
private readonly LiveEntityRuntime _liveEntities;
private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, uint, float, bool> _playTyped;
private readonly Action<uint, bool> _setDirectChildrenNoDraw;
private readonly Action<uint> _clearInvalidTarget;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action<uint>? _onShadowRestored;
private readonly Dictionary<uint, ushort> _readyGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _suspendedShadowGenerationByGuid = new();
private readonly Dictionary<uint, ushort> _activePlacementGenerationByGuid = new();
private bool _disposed;
public LiveEntityPresentationController(
LiveEntityRuntime liveEntities,
ShadowObjectRegistry shadows,
Func<uint, uint, float, bool> playTyped,
Action<uint, bool>? setDirectChildrenNoDraw = null,
Action<uint>? clearInvalidTarget = null,
Func<(int X, int Y)>? liveCenter = null,
Action<uint>? onShadowRestored = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
_liveCenter = liveCenter ?? (() => (0, 0));
_onShadowRestored = onShadowRestored;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
/// <summary>
/// Opens the state-side-effect barrier after render/effect owners have
/// registered, then drains constructor and pre-materialization transitions
/// exactly once in accepted order.
/// </summary>
public bool OnLiveEntityReady(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is null
|| !record.ResourcesRegistered)
{
return false;
}
_readyGenerationByGuid[serverGuid] = record.Generation;
ApplyPendingTransitions(record);
return true;
}
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
public bool OnStateAccepted(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| !_readyGenerationByGuid.TryGetValue(serverGuid, out ushort generation)
|| generation != record.Generation)
{
return false;
}
ApplyPendingTransitions(record);
return true;
}
/// <summary>
/// Retains ownership of a collision shadow that another retail transition
/// suspended while this incarnation's spatial projection is unavailable.
/// Hidden/UnHide and teleport rollback intentionally converge on this one
/// generation-scoped restoration marker.
/// </summary>
public bool DeferShadowRestore(uint serverGuid, ushort generation)
=> CompleteAuthoritativePlacement(serverGuid, generation, deferShadowRestore: true);
/// <summary>
/// Completes active placement ownership after its final frame/cell is
/// known, either returning a stable deferred restore to presentation or
/// clearing the suspension for an immediate controller-owned restore.
/// </summary>
public bool CompleteAuthoritativePlacement(
uint serverGuid,
ushort generation,
bool deferShadowRestore)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.Generation != generation
|| record.WorldEntity is null)
{
return false;
}
if (_activePlacementGenerationByGuid.TryGetValue(
serverGuid,
out ushort activeGeneration)
&& activeGeneration == generation)
{
_activePlacementGenerationByGuid.Remove(serverGuid);
}
if (deferShadowRestore)
_suspendedShadowGenerationByGuid[serverGuid] = generation;
else if (_suspendedShadowGenerationByGuid.TryGetValue(
serverGuid,
out ushort deferredGeneration)
&& deferredGeneration == generation)
_suspendedShadowGenerationByGuid.Remove(serverGuid);
return true;
}
/// <summary>
/// Transfers a deferred restore to a newer authoritative placement before
/// that placement rebuckets and can become visible. The placement hands
/// ownership back after it reaches a stable Hidden destination/rollback;
/// this prevents an intervening UnHide from restoring an unresolved pose.
/// </summary>
public bool BeginAuthoritativePlacement(uint serverGuid, ushort generation)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.Generation != generation)
{
return false;
}
_activePlacementGenerationByGuid[serverGuid] = generation;
if (_suspendedShadowGenerationByGuid.TryGetValue(
serverGuid,
out ushort deferredGeneration)
&& deferredGeneration == generation)
{
_suspendedShadowGenerationByGuid.Remove(serverGuid);
}
return true;
}
internal bool HasDeferredShadowRestore(uint serverGuid) =>
_suspendedShadowGenerationByGuid.ContainsKey(serverGuid);
internal bool HasActivePlacement(uint serverGuid) =>
_activePlacementGenerationByGuid.ContainsKey(serverGuid);
public void Forget(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort generation)
&& generation == record.Generation)
{
_readyGenerationByGuid.Remove(record.ServerGuid);
}
if (_suspendedShadowGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort suspendedGeneration)
&& suspendedGeneration == record.Generation)
{
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
}
if (_activePlacementGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort activeGeneration)
&& activeGeneration == record.Generation)
{
_activePlacementGenerationByGuid.Remove(record.ServerGuid);
}
}
public void Clear()
{
_readyGenerationByGuid.Clear();
_suspendedShadowGenerationByGuid.Clear();
_activePlacementGenerationByGuid.Clear();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
Clear();
}
private void ApplyPendingTransitions(LiveEntityRecord record)
{
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
{
if (record.WorldEntity is not { } entity)
return;
// set_state writes the final state before any PartArray/cell side
// effect. Retained shadow registrations must see those same bits
// even while their cell rows are suspended.
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
switch (transition.HiddenTransition)
{
case RetailHiddenTransition.BecameHidden:
_playTyped(entity.Id, HiddenScriptType, 1f);
_setDirectChildrenNoDraw(record.ServerGuid, true);
_shadows.Suspend(entity.Id);
if (!IsPlacementActive(record))
_suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation;
_clearInvalidTarget(record.ServerGuid);
break;
case RetailHiddenTransition.BecameVisible:
_playTyped(entity.Id, UnHideScriptType, 1f);
_setDirectChildrenNoDraw(record.ServerGuid, false);
if (!IsPlacementActive(record) && RestoreShadow(record, entity))
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
break;
}
}
}
private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity)
{
if (!record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible
|| record.FullCellId == 0)
{
return false;
}
(int centerX, int centerY) = _liveCenter();
ShadowPositionSynchronizer.Sync(
_shadows,
entity.Id,
entity.Position,
entity.Rotation,
record.FullCellId,
centerX,
centerY);
_onShadowRestored?.Invoke(record.ServerGuid);
return true;
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (!visible
|| record.WorldEntity is not { } entity
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|| IsPlacementActive(record)
|| !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration)
|| readyGeneration != record.Generation
|| !_suspendedShadowGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort suspendedGeneration)
|| suspendedGeneration != record.Generation)
{
return;
}
if (RestoreShadow(record, entity))
_suspendedShadowGenerationByGuid.Remove(record.ServerGuid);
}
private bool IsPlacementActive(LiveEntityRecord record) =>
_activePlacementGenerationByGuid.TryGetValue(
record.ServerGuid,
out ushort generation)
&& generation == record.Generation;
}

View file

@ -3,6 +3,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using System.Numerics;
namespace AcDream.App.World; namespace AcDream.App.World;
@ -32,6 +33,24 @@ public interface ILiveEntityCanonicalCellConsumer
void BindCanonicalCell(Func<uint> read, Action<uint> write); void BindCanonicalCell(Func<uint> read, Action<uint> write);
} }
/// <summary>
/// Remote CPhysicsObj state required to finish a retail MoveOrTeleport
/// placement. A same-incarnation runtime replacement may wrap the same body,
/// but it must preserve this complete placement contract.
/// </summary>
public interface ILiveEntityRemotePlacementRuntime :
ILiveEntityRemoteMotionRuntime,
ILiveEntityCanonicalCellConsumer
{
uint CellId { get; set; }
bool Airborne { get; set; }
Vector3 LastServerPosition { get; set; }
double LastServerPositionTime { get; set; }
Vector3 LastShadowSyncPosition { get; set; }
void HitGround();
void LeaveGround();
}
/// <summary>Projectile physics state owned by a live object.</summary> /// <summary>Projectile physics state owned by a live object.</summary>
public interface ILiveEntityProjectileRuntime public interface ILiveEntityProjectileRuntime
{ {
@ -83,11 +102,15 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
/// </summary> /// </summary>
public sealed class LiveEntityRecord public sealed class LiveEntityRecord
{ {
private readonly Queue<RetailPhysicsStateTransition> _pendingStateTransitions = new();
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot) internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
{ {
ServerGuid = snapshot.Guid; ServerGuid = snapshot.Guid;
Snapshot = snapshot; Snapshot = snapshot;
RefreshDerivedState(); RefreshDerivedState();
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
ApplyRawPhysicsState(RawPhysicsState);
} }
public uint ServerGuid { get; } public uint ServerGuid { get; }
@ -102,6 +125,7 @@ public sealed class LiveEntityRecord
public PhysicsBody? PhysicsBody { get; internal set; } public PhysicsBody? PhysicsBody { get; internal set; }
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
internal bool RequiresRemotePlacementRuntime { get; set; }
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; } public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
public ILiveEntityEffectProfile? EffectProfile { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; } public bool ResourcesRegistered { get; internal set; }
@ -110,6 +134,31 @@ public sealed class LiveEntityRecord
public bool WorldSpawnPublished { get; internal set; } public bool WorldSpawnPublished { get; internal set; }
public LiveEntityProjectionKind ProjectionKind { get; internal set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; }
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
_pendingStateTransitions.TryDequeue(out transition);
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
{
RawPhysicsState = rawState;
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
FinalPhysicsState,
(PhysicsStateFlags)rawState);
FinalPhysicsState = transition.FinalState;
if (PhysicsBody is not null)
PhysicsBody.State = FinalPhysicsState;
_pendingStateTransitions.Enqueue(transition);
return transition;
}
internal void SetChildNoDraw(bool noDraw)
{
FinalPhysicsState = noDraw
? FinalPhysicsState | PhysicsStateFlags.NoDraw
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
if (PhysicsBody is not null)
PhysicsBody.State = FinalPhysicsState;
}
/// <summary> /// <summary>
/// Cell used when a streamed landblock asks live objects to restore their /// Cell used when a streamed landblock asks live objects to restore their
/// spatial projection. Once materialized, the canonical runtime cell wins; /// spatial projection. Once materialized, the canonical runtime cell wins;
@ -129,7 +178,6 @@ public sealed class LiveEntityRecord
RawPhysicsState = Snapshot.Physics?.RawState RawPhysicsState = Snapshot.Physics?.RawState
?? Snapshot.PhysicsState ?? Snapshot.PhysicsState
?? 0u; ?? 0u;
FinalPhysicsState = (PhysicsStateFlags)RawPhysicsState;
} }
} }
@ -205,8 +253,9 @@ public sealed class LiveEntityRuntime
/// <summary> /// <summary>
/// Currently visible top-level world projections. Attached children and /// Currently visible top-level world projections. Attached children and
/// pending projections are excluded from radar, picking, status, and target /// pending or Hidden projections are excluded from radar, picking, status,
/// candidate consumers. /// and target candidate consumers. NoDraw alone suppresses the mesh but is
/// not a retail cell-visibility transition.
/// </summary> /// </summary>
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities => public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
_visibleWorldEntitiesByGuid; _visibleWorldEntitiesByGuid;
@ -330,6 +379,15 @@ public sealed class LiveEntityRuntime
} }
record.ProjectionKind = projectionKind; record.ProjectionKind = projectionKind;
if (projectionKind is LiveEntityProjectionKind.World)
{
// Attached projections inherit the complete ancestor visibility
// chain. Once the same logical entity becomes a top-level world
// projection it has no render ancestor, so retained inherited
// suppression must be cleared at this ownership transition.
record.WorldEntity!.IsAncestorDrawVisible = true;
}
RefreshPresentation(record);
RebucketLiveEntity(serverGuid, fullCellId); RebucketLiveEntity(serverGuid, fullCellId);
return record.WorldEntity; return record.WorldEntity;
} }
@ -362,10 +420,7 @@ public sealed class LiveEntityRuntime
_materializedWorldEntitiesByGuid[serverGuid] = entity; _materializedWorldEntitiesByGuid[serverGuid] = entity;
else else
_materializedWorldEntitiesByGuid.Remove(serverGuid); _materializedWorldEntitiesByGuid.Remove(serverGuid);
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World) RefreshPresentation(record);
_visibleWorldEntitiesByGuid[serverGuid] = entity;
else
_visibleWorldEntitiesByGuid.Remove(serverGuid);
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu) if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
record.FullCellId = spatialCellOrLandblockId; record.FullCellId = spatialCellOrLandblockId;
record.CanonicalLandblockId = spatialCellOrLandblockId == 0 record.CanonicalLandblockId = spatialCellOrLandblockId == 0
@ -387,13 +442,38 @@ public sealed class LiveEntityRuntime
return false; return false;
_spatial.RemoveLiveEntityProjection(serverGuid); _spatial.RemoveLiveEntityProjection(serverGuid);
// Usually GpuWorldState delivers the false edge synchronously above.
// During a reentrant visibility callback it queues that edge until the
// outer notification completes, so publish the logical withdrawal now;
// OnSpatialVisibilityChanged rejects the later duplicate against this
// committed record state.
bool spatialEdgeWasDeferred = record.IsSpatiallyVisible;
_materializedWorldEntitiesByGuid.Remove(serverGuid); _materializedWorldEntitiesByGuid.Remove(serverGuid);
_visibleWorldEntitiesByGuid.Remove(serverGuid); _visibleWorldEntitiesByGuid.Remove(serverGuid);
record.IsSpatiallyProjected = false; record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false; record.IsSpatiallyVisible = false;
if (spatialEdgeWasDeferred)
ProjectionVisibilityChanged?.Invoke(record, false);
return true; return true;
} }
/// <summary>
/// Withdraws a still-live projection whose authoritative rollback frame
/// is outside every world cell. Logical owners remain registered.
/// </summary>
internal bool WithdrawLiveEntityProjectionToCellless(uint serverGuid)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is null)
{
return false;
}
ClearWorldCell(serverGuid);
record.WorldEntity.ParentCellId = 0u;
return WithdrawLiveEntityProjection(serverGuid);
}
public bool UnregisterLiveEntity( public bool UnregisterLiveEntity(
DeleteObject.Parsed delete, DeleteObject.Parsed delete,
bool isLocalPlayer, bool isLocalPlayer,
@ -449,6 +529,16 @@ public sealed class LiveEntityRuntime
return false; return false;
} }
/// <summary>
/// Resolves a top-level object that currently participates in picking,
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
/// Pending, attached, and Hidden projections are intentionally excluded.
/// </summary>
public bool TryGetInteractionEligibleEntity(
uint serverGuid,
out WorldEntity entity) =>
_visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!);
public bool ContainsWorldEntity(uint serverGuid) => public bool ContainsWorldEntity(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.WorldEntity is not null; && record.WorldEntity is not null;
@ -503,14 +593,24 @@ public sealed class LiveEntityRuntime
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
if (record.ProjectileRuntime is { } projectile PhysicsBody candidateBody = runtime.Body;
&& !ReferenceEquals(projectile.Body, runtime.Body)) if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body."); $"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
} }
if (record.RequiresRemotePlacementRuntime
&& runtime is not ILiveEntityRemotePlacementRuntime)
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation.");
}
record.RequiresRemotePlacementRuntime |=
runtime is ILiveEntityRemotePlacementRuntime;
record.RemoteMotionRuntime = runtime; record.RemoteMotionRuntime = runtime;
record.PhysicsBody = runtime.Body; record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer)
{ {
// Capture the incarnation, not only its GUID. A callback retained // Capture the incarnation, not only its GUID. A callback retained
@ -548,8 +648,6 @@ public sealed class LiveEntityRuntime
return false; return false;
_remoteMotionByGuid.Remove(serverGuid); _remoteMotionByGuid.Remove(serverGuid);
record.RemoteMotionRuntime = null; record.RemoteMotionRuntime = null;
if (record.ProjectileRuntime is null)
record.PhysicsBody = null;
return true; return true;
} }
@ -562,21 +660,17 @@ public sealed class LiveEntityRuntime
throw new InvalidOperationException( throw new InvalidOperationException(
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized."); $"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
} }
if (record.RemoteMotionRuntime is { } remote PhysicsBody candidateBody = runtime.Body;
&& !ReferenceEquals(remote.Body, runtime.Body)) if (record.PhysicsBody is { } canonicalBody
&& !ReferenceEquals(canonicalBody, candidateBody))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns a different remote-motion physics body."); $"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
}
if (record.ProjectileRuntime is { } projectile
&& !ReferenceEquals(projectile.Body, runtime.Body))
{
throw new InvalidOperationException(
$"Live entity 0x{serverGuid:X8} already owns a different projectile physics body.");
} }
record.ProjectileRuntime = runtime; record.ProjectileRuntime = runtime;
record.PhysicsBody = runtime.Body; record.PhysicsBody = candidateBody;
candidateBody.State = record.FinalPhysicsState;
} }
public bool TryGetProjectileRuntime( public bool TryGetProjectileRuntime(
@ -601,8 +695,6 @@ public sealed class LiveEntityRuntime
return false; return false;
record.ProjectileRuntime = null; record.ProjectileRuntime = null;
if (record.RemoteMotionRuntime is null)
record.PhysicsBody = null;
return true; return true;
} }
@ -694,12 +786,40 @@ public sealed class LiveEntityRuntime
} }
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted) public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
=> TryApplyState(update, out accepted, out _);
public bool TryApplyState(
SetState.Parsed update,
out WorldSession.EntitySpawn accepted,
out RetailPhysicsStateTransition transition)
{ {
bool applied = _inbound.TryApplyState(update, out accepted); bool applied = _inbound.TryApplyState(update, out accepted);
if (applied) RefreshRecord(update.Guid, accepted); transition = default;
if (applied)
{
RefreshRecord(update.Guid, accepted);
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
{
transition = record.ApplyRawPhysicsState(update.PhysicsState);
RefreshPresentation(record);
}
}
return applied; return applied;
} }
/// <summary>
/// Retail parent Hidden directly toggles each attached child's NoDraw bit
/// without consuming the child's STATE_TS or replacing its raw wire state.
/// </summary>
public bool SetAttachedChildNoDraw(uint childServerGuid, bool noDraw)
{
if (!_recordsByGuid.TryGetValue(childServerGuid, out LiveEntityRecord? record))
return false;
record.SetChildNoDraw(noDraw);
RefreshPresentation(record);
return true;
}
public bool TryApplyPosition( public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update, WorldSession.EntityPositionUpdate update,
bool isLocalPlayer, bool isLocalPlayer,
@ -709,6 +829,12 @@ public sealed class LiveEntityRuntime
out WorldSession.EntitySpawn accepted, out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps) out AcceptedPhysicsTimestamps timestamps)
{ {
bool wasCellLess = _recordsByGuid.TryGetValue(
update.Guid,
out LiveEntityRecord? beforePosition)
&& (beforePosition.FullCellId == 0
|| !beforePosition.IsSpatiallyProjected
|| !beforePosition.IsSpatiallyVisible);
bool known = _inbound.TryApplyPosition( bool known = _inbound.TryApplyPosition(
update, update,
isLocalPlayer, isLocalPlayer,
@ -720,6 +846,13 @@ public sealed class LiveEntityRuntime
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
{ {
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected; bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
if (disposition is PositionTimestampDisposition.Apply)
{
timestamps = timestamps with
{
TeleportHookRequired = timestamps.TeleportAdvanced || wasCellLess,
};
}
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition); RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
if (acceptedPosition) if (acceptedPosition)
{ {
@ -734,8 +867,11 @@ public sealed class LiveEntityRuntime
/// <summary> /// <summary>
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root /// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
/// movement/animation only while the object has visible world-cell /// object tick only while the object has visible world-cell membership and
/// membership and is not Frozen. A pickup or parent transition keeps /// is not Frozen. Hidden is deliberately not a broad tick stop: retail
/// skips PartArray animation and physics, but still applies the
/// PositionManager offset and advances targeting/movement/script/particle
/// owners. A pickup or parent transition keeps
/// script/effect owners but clears the cell and withdraws the root /// script/effect owners but clears the cell and withdraws the root
/// projection until a fresh Position re-enters it. /// projection until a fresh Position re-enters it.
/// </summary> /// </summary>
@ -746,6 +882,10 @@ public sealed class LiveEntityRuntime
&& record.FullCellId != 0 && record.FullCellId != 0
&& (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0; && (record.FinalPhysicsState & PhysicsStateFlags.Frozen) == 0;
public bool IsHidden(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
/// <summary> /// <summary>
/// Claims the one logical top-level spawn notification for this object /// Claims the one logical top-level spawn notification for this object
/// incarnation. Spatial re-entry must restore presentation without replaying /// incarnation. Spatial re-entry must restore presentation without replaying
@ -832,15 +972,31 @@ public sealed class LiveEntityRuntime
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
return; return;
bool wasVisible = record.IsSpatiallyVisible;
record.IsSpatiallyVisible = visible; record.IsSpatiallyVisible = visible;
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World) RefreshPresentation(record);
_visibleWorldEntitiesByGuid[serverGuid] = entity; if (_rebucketingGuid != serverGuid && wasVisible != visible)
else
_visibleWorldEntitiesByGuid.Remove(serverGuid);
if (_rebucketingGuid != serverGuid)
ProjectionVisibilityChanged?.Invoke(record, visible); ProjectionVisibilityChanged?.Invoke(record, visible);
} }
private void RefreshPresentation(LiveEntityRecord record)
{
if (record.WorldEntity is not { } entity)
return;
PhysicsStateFlags state = record.FinalPhysicsState;
entity.IsDrawVisible =
(state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
bool interactionVisible = record.IsSpatiallyVisible
&& record.ProjectionKind is LiveEntityProjectionKind.World
&& (state & PhysicsStateFlags.Hidden) == 0;
if (interactionVisible)
_visibleWorldEntitiesByGuid[record.ServerGuid] = entity;
else
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
}
private void TearDownRecord(LiveEntityRecord record) private void TearDownRecord(LiveEntityRecord record)
{ {
List<Exception>? failures = null; List<Exception>? failures = null;
@ -873,6 +1029,7 @@ public sealed class LiveEntityRuntime
_remoteMotionByGuid.Remove(record.ServerGuid); _remoteMotionByGuid.Remove(record.ServerGuid);
record.AnimationRuntime = null; record.AnimationRuntime = null;
record.RemoteMotionRuntime = null; record.RemoteMotionRuntime = null;
record.RequiresRemotePlacementRuntime = false;
record.PhysicsBody = null; record.PhysicsBody = null;
record.ProjectileRuntime = null; record.ProjectileRuntime = null;
record.EffectProfile = null; record.EffectProfile = null;

View file

@ -0,0 +1,33 @@
namespace AcDream.App.World;
/// <summary>
/// Runs the independent owners that participate in one live incarnation's
/// teardown without letting an earlier callback strand later state. Retail's
/// object destruction drains these owners as separate lifecycle steps; the App
/// composition callback must preserve that symmetry even when a plugin,
/// renderer, or effect sink throws.
/// </summary>
internal static class LiveEntityTeardown
{
internal static void Run(IEnumerable<Action> cleanups)
{
ArgumentNullException.ThrowIfNull(cleanups);
List<Exception>? failures = null;
foreach (Action cleanup in cleanups)
{
try
{
cleanup();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null)
throw new AggregateException(
"One or more live-entity component teardown steps failed.",
failures);
}
}

View file

@ -297,4 +297,22 @@ public sealed class TargetManager
foreach (var voyeur in _voyeurTable.Values.ToList()) foreach (var voyeur in _voyeurTable.Values.ToList())
SendVoyeurUpdate(voyeur, _host.Position, status); SendVoyeurUpdate(voyeur, _host.Position, status);
} }
/// <summary>
/// Broadcasts a terminal availability event and drops the watched-role
/// subscriptions after delivery. Retail normally removes the object from
/// cell lookup before its watchers process the event; clearing the local
/// table explicitly preserves that lifecycle boundary when the App uses
/// the target fan-out as the temporary bridge for an unavailable object.
/// The object's own watcher role (<see cref="TargetInfo"/>) is untouched.
/// </summary>
public void NotifyVoyeurOfEventAndClear(TargetStatus status)
{
if (_voyeurTable == null)
return;
foreach (var voyeur in _voyeurTable.Values.ToList())
SendVoyeurUpdate(voyeur, _host.Position, status);
_voyeurTable.Clear();
}
} }

View file

@ -1388,7 +1388,11 @@ public sealed class PhysicsEngine
bool ok = transition.FindPlacementPos(this); bool ok = transition.FindPlacementPos(this);
var sp = transition.SpherePath; var sp = transition.SpherePath;
var ci = transition.CollisionInfo; var ci = transition.CollisionInfo;
bool onGround = ci.ContactPlaneValid bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
return new ResolveResult( return new ResolveResult(
@ -1397,6 +1401,12 @@ public sealed class PhysicsEngine
onGround, onGround,
ci.CollisionNormalValid, ci.CollisionNormalValid,
ci.CollisionNormal, ci.CollisionNormal,
ok); ok,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable,
ContactPlane: ci.ContactPlane,
ContactPlaneCellId: ci.ContactPlaneCellId,
ContactPlaneIsWater: ci.ContactPlaneIsWater);
} }
} }

View file

@ -45,6 +45,70 @@ public static class PhysicsObjUpdate
body.calc_acceleration(); body.calc_acceleration();
} }
/// <summary>
/// Commits the contact/walkable/collision-response tail of retail
/// <c>CPhysicsObj::SetPositionInternal</c> (<c>0x00515330</c>) in its
/// original order. The pre-transition flags are explicit because a
/// deferred placement may temporarily park the body without contact while
/// still needing the source edge when its destination cell becomes ready.
/// </summary>
/// <remarks>
/// Retail writes Contact and recalculates acceleration, calls
/// <c>set_on_walkable</c> (which invokes HitGround/LeaveGround), then calls
/// <c>handle_all_collisions</c> at <c>0x005154FE</c>. Collision response
/// must therefore observe any velocity change made by the movement
/// callback; moving that callback after reflection changes the result.
/// </remarks>
public static void CommitSetPositionTransition(
PhysicsBody body,
bool inContact,
bool onWalkable,
bool collisionNormalValid,
Vector3 collisionNormal,
bool previousContact,
bool previousOnWalkable,
Action? hitGround = null,
Action? leaveGround = null)
{
ArgumentNullException.ThrowIfNull(body);
// SetPositionInternal replaces Contact first but retains the source
// OnWalkable bit through its first calc_acceleration call. A deferred
// teleport may have parked the live body with both bits cleared, so
// restore the captured source bit explicitly before reproducing that
// ordering.
if (previousOnWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (inContact)
body.TransientState |= TransientStateFlags.Contact;
else
body.TransientState &= ~TransientStateFlags.Contact;
body.calc_acceleration();
bool finalOnWalkable = inContact && onWalkable;
if (finalOnWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (!previousOnWalkable && finalOnWalkable)
hitGround?.Invoke();
else if (previousOnWalkable && !finalOnWalkable)
leaveGround?.Invoke();
body.calc_acceleration();
HandleAllCollisions(
body,
collisionNormalValid,
collisionNormal,
previousContact,
previousOnWalkable,
finalOnWalkable);
}
/// <summary> /// <summary>
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's /// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on /// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on

View file

@ -51,4 +51,10 @@ public readonly record struct ResolveResult(
/// Exact retail walkability result after testing the contact-plane normal /// Exact retail walkability result after testing the contact-plane normal
/// against <see cref="PhysicsGlobals.FloorZ"/>. /// against <see cref="PhysicsGlobals.FloorZ"/>.
/// </summary> /// </summary>
bool OnWalkable = false); bool OnWalkable = false,
/// <summary>The accepted SetPosition contact plane, when present.</summary>
Plane ContactPlane = default,
/// <summary>Full cell that owns <see cref="ContactPlane"/>.</summary>
uint ContactPlaneCellId = 0,
/// <summary>Whether the accepted contact plane is water.</summary>
bool ContactPlaneIsWater = false);

View file

@ -0,0 +1,89 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Result of retail <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>) and
/// the nested <c>set_hidden</c> call (<c>0x00514C60</c>).
/// </summary>
public readonly record struct RetailPhysicsStateTransition(
PhysicsStateFlags PreviousState,
PhysicsStateFlags RequestedState,
PhysicsStateFlags FinalState,
bool LightingChanged,
bool NoDrawChanged,
RetailHiddenTransition HiddenTransition)
{
public bool HasChanges => PreviousState != FinalState;
}
public enum RetailHiddenTransition
{
None,
BecameHidden,
BecameVisible,
}
/// <summary>
/// Pure port of retail's PhysicsState side-effect ordering. The server's raw
/// state is installed first; Lighting and NoDraw are processed next; Hidden is
/// processed last and may rewrite ReportCollisions/IgnoreCollisions.
/// </summary>
public static class RetailPhysicsStateTransitions
{
/// <summary>
/// Retail <c>CPhysicsObj</c> constructor state at <c>0x00512508</c>.
/// This deliberately does not contain Hidden: a normal visible
/// CreateObject must not manufacture an UnHide script.
/// </summary>
public const PhysicsStateFlags ConstructorState =
PhysicsStateFlags.EdgeSlide
| PhysicsStateFlags.Lighting
| PhysicsStateFlags.Gravity
| PhysicsStateFlags.ReportCollisions;
public static RetailPhysicsStateTransition Apply(
PhysicsStateFlags previousState,
PhysicsStateFlags requestedState)
{
// CPhysicsObj::set_state compares only the low 16 bits. Every state
// with a set_state side effect (Lighting, NoDraw, Hidden) lives there.
uint changedLow = ((uint)previousState ^ (uint)requestedState) & 0xFFFFu;
bool lightingChanged =
(changedLow & (uint)PhysicsStateFlags.Lighting) != 0;
bool noDrawChanged =
(changedLow & (uint)PhysicsStateFlags.NoDraw) != 0;
bool hiddenChanged =
(changedLow & (uint)PhysicsStateFlags.Hidden) != 0;
PhysicsStateFlags finalState = requestedState;
RetailHiddenTransition hiddenTransition = RetailHiddenTransition.None;
if (hiddenChanged)
{
if ((requestedState & PhysicsStateFlags.Hidden) != 0)
{
// set_hidden(true): report_collision_end, clear reporting,
// then enable collision-ignore before hiding from the cell.
finalState &= ~PhysicsStateFlags.ReportCollisions;
finalState |= PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions;
hiddenTransition = RetailHiddenTransition.BecameHidden;
}
else
{
// set_hidden(false): clear ignore and restore collision
// reporting even when the incoming raw state omitted it.
finalState &= ~(PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions);
finalState |= PhysicsStateFlags.ReportCollisions;
hiddenTransition = RetailHiddenTransition.BecameVisible;
}
}
return new RetailPhysicsStateTransition(
previousState,
requestedState,
finalState,
lightingChanged,
noDrawChanged,
hiddenTransition);
}
}

View file

@ -30,6 +30,22 @@ public sealed class WorldEntity
/// </summary> /// </summary>
public required IReadOnlyList<MeshRef> MeshRefs { get; set; } public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
/// <summary>
/// Whether the root PartArray participates in mesh drawing. Live-object
/// PhysicsState NoDraw/Hidden transitions change this without destroying
/// the entity or its scripts, particles, lights, cell identity, or cached
/// mesh resources. Dat/static entities remain visible by default.
/// </summary>
public bool IsDrawVisible { get; set; } = true;
/// <summary>
/// Retained-tree visibility inherited from attached ancestors. Retail
/// draws child physics objects through their parent hierarchy; acdream
/// flattens them into independent draw entries, so this separate bit keeps
/// ancestor suppression out of the child's own PhysicsState.
/// </summary>
public bool IsAncestorDrawVisible { get; set; } = true;
/// <summary> /// <summary>
/// Stable Setup-part-indexed poses used by attachment and effect hooks. /// Stable Setup-part-indexed poses used by attachment and effect hooks.
/// Unlike <see cref="MeshRefs"/>, this array never compacts around a DAT /// Unlike <see cref="MeshRefs"/>, this array never compacts around a DAT

View file

@ -0,0 +1,82 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
namespace AcDream.App.Tests.Physics;
public sealed class PlayerMovementHiddenTests
{
[Fact]
public void TickHidden_PositionManagerMovesRootAndEffectPoseWithoutPhysicsInput()
{
const uint playerGuid = 0x50000001u;
const uint targetGuid = 0x50000002u;
const uint cellId = 0x01010001u;
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SetPosition(Vector3.Zero, cellId, Vector3.Zero);
var hosts = new Dictionary<uint, IPhysicsObjHost>();
EntityPhysicsHost? playerHost = null;
playerHost = new EntityPhysicsHost(
playerGuid,
() => controller.CellPosition,
() => controller.BodyVelocity,
() => 0.48f,
() => controller.BodyInContact,
() => 3f,
() => 0.1,
() => 0.1,
id => hosts.GetValueOrDefault(id),
info => playerHost!.PositionManager.HandleUpdateTarget(info),
() => { });
var targetPosition = new Position(
cellId,
new Vector3(5f, 0f, 0f),
Quaternion.Identity);
var targetHost = new EntityPhysicsHost(
targetGuid,
() => targetPosition,
() => Vector3.Zero,
() => 0.48f,
() => true,
() => null,
() => 0.1,
() => 0.1,
id => hosts.GetValueOrDefault(id),
_ => { },
() => { });
hosts.Add(playerGuid, playerHost);
hosts.Add(targetGuid, targetHost);
controller.PositionManager = playerHost.PositionManager;
playerHost.PositionManager.StickTo(targetGuid, radius: 0.48f, height: 1.835f);
var entity = new WorldEntity
{
Id = 7u,
ServerGuid = playerGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
ParentCellId = cellId,
MeshRefs = Array.Empty<MeshRef>(),
};
var poses = new EntityEffectPoseRegistry();
poses.Publish(entity, Array.Empty<Matrix4x4>());
MovementResult result = controller.TickHidden(0.1f);
entity.SetPosition(result.Position);
entity.ParentCellId = result.CellId;
Assert.True(poses.UpdateRoot(entity));
Assert.InRange(result.Position.X, 0.01f, 1.5f);
Assert.Equal(0f, result.Position.Z);
Assert.Equal(Vector3.Zero, controller.BodyVelocity);
Assert.True(poses.TryGetRootPose(entity.Id, out Matrix4x4 root));
Assert.Equal(result.Position, root.Translation);
}
}

View file

@ -52,6 +52,96 @@ public sealed class ProjectileControllerTests
Assert.Same(record.ProjectileRuntime!.Body, record.PhysicsBody); Assert.Same(record.ProjectileRuntime!.Body, record.PhysicsBody);
} }
[Fact]
public void Hidden_PausesProjectileWithoutClearingActiveOrReplayingClockBacklog()
{
var fixture = new Fixture();
LiveEntityRecord record = fixture.Spawn(instance: 1);
WorldEntity entity = record.WorldEntity!;
fixture.Engine.ShadowObjects.Register(
entity.Id,
entity.SourceGfxObjOrSetupId,
entity.Position,
entity.Rotation,
radius: 0.5f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x01010000u,
state: (uint)MissileState,
seedCellId: CellA);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
PhysicsBody body = record.PhysicsBody!;
Assert.True(body.IsActive);
record.FinalPhysicsState = MissileState
| PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 1.1, 1, 1));
Vector3 hiddenPosition = entity.Position;
fixture.Controller.Tick(5.0, 1, 1, playerWorldPosition: null);
Assert.Equal(hiddenPosition, entity.Position);
Assert.True(body.InWorld);
Assert.True(body.IsActive);
Assert.Equal(5.0, body.LastUpdateTime, 8);
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
record.FinalPhysicsState = MissileState;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 5.0, 1, 1));
fixture.Controller.Tick(5.1, 1, 1, playerWorldPosition: null);
Assert.True(entity.Position.X > hiddenPosition.X);
Assert.True(entity.Position.X < hiddenPosition.X + 2f);
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
}
[Fact]
public void HiddenSharedRemoteProjectile_UsesPositionManagerNarrowTickExactlyOnce()
{
var fixture = new Fixture();
LiveEntityRecord record = fixture.Spawn(instance: 1);
WorldEntity entity = record.WorldEntity!;
Assert.Null(record.AnimationRuntime);
var remote = new GameWindow.RemoteMotion();
RemotePhysicsBodyInitializer.Initialize(remote.Body, record);
remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation;
fixture.Live.SetRemoteMotionRuntime(Guid, remote);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
Assert.Same(remote.Body, record.ProjectileRuntime!.Body);
record.FinalPhysicsState = MissileState
| PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions;
Assert.True(fixture.Controller.ApplyAuthoritativeState(
Guid, record.FinalPhysicsState, 1.1, 1, 1));
remote.Interp.Enqueue(
entity.Position + Vector3.UnitX,
heading: 0f,
isMovingTo: false,
currentBodyPosition: remote.Body.Position);
var updater = new RemotePhysicsUpdater(
fixture.Engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
int publishedRoots = 0;
updater.TickHiddenEntities(
fixture.Live,
localPlayerServerGuid: 0x50000001u,
dt: 0.1f,
_ => publishedRoots++);
Vector3 afterNarrowTick = entity.Position;
fixture.Controller.Tick(1.2, 1, 1, playerWorldPosition: null);
Assert.True(afterNarrowTick.X > 10f);
Assert.Equal(afterNarrowTick, entity.Position);
Assert.Equal(1, publishedRoots);
Assert.True(fixture.Controller.HandlesMovement(Guid));
}
[Fact] [Fact]
public void PendingDestinationAndLaterHydration_RetainIdentityAndProjectileOwner() public void PendingDestinationAndLaterHydration_RetainIdentityAndProjectileOwner()
{ {

View file

@ -0,0 +1,166 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Tests.Physics;
public sealed class RemotePhysicsUpdaterTests
{
[Fact]
public void TickHidden_AppliesPositionManagerOffsetWithoutAdvancingPhysics()
{
var motion = new GameWindow.RemoteMotion();
motion.Body.Position = Vector3.Zero;
motion.Body.Orientation = Quaternion.Identity;
motion.Body.Velocity = new Vector3(4f, 0f, 5f);
motion.Body.Acceleration = new Vector3(0f, 0f, -9.8f);
motion.CellId = 0x01010001u;
motion.Interp.Enqueue(
new Vector3(1f, 0f, 0f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: Vector3.Zero);
var entity = new WorldEntity
{
Id = 1u,
ServerGuid = 0x70000001u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
var updater = new RemotePhysicsUpdater(
new PhysicsEngine(),
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var poses = new EntityEffectPoseRegistry();
poses.Publish(entity, Array.Empty<Matrix4x4>());
updater.TickHidden(motion, entity, 0.1f);
Assert.True(poses.UpdateRoot(entity));
Assert.InRange(motion.Body.Position.X, 0.01f, 1f);
Assert.Equal(0f, motion.Body.Position.Z);
Assert.Equal(new Vector3(4f, 0f, 5f), motion.Body.Velocity);
Assert.Equal(motion.Body.Position, entity.Position);
Assert.Equal(motion.CellId, entity.ParentCellId);
Assert.True(poses.TryGetRootPose(entity.Id, out Matrix4x4 root));
Assert.Equal(entity.Position, root.Translation);
}
[Fact]
public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide()
{
var engine = BuildBoundaryEngine();
var updater = new RemotePhysicsUpdater(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new GameWindow.RemoteMotion();
motion.Body.Position = new Vector3(191f, 10f, 50f);
motion.Body.Orientation = Quaternion.Identity;
motion.Body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
uint canonicalCell = 0xA9B40039u;
int canonicalWrites = 0;
motion.BindCanonicalCell(
() => canonicalCell,
value =>
{
canonicalCell = value;
canonicalWrites++;
});
motion.CellId = canonicalCell;
canonicalWrites = 0;
motion.Interp.Enqueue(
new Vector3(193f, 10f, 50f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: motion.Body.Position);
var entity = new WorldEntity
{
Id = 1u,
ServerGuid = 0x70000002u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = motion.Body.Position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
updater.TickHidden(motion, entity, 2f);
Assert.Equal(0xAAB40001u, canonicalCell);
Assert.Equal(canonicalCell, entity.ParentCellId);
Assert.True(canonicalWrites > 0);
Assert.True(entity.Position.X > 192f);
}
[Fact]
public void TickHidden_LandingSynchronizesRemoteAirborneState()
{
var engine = BuildBoundaryEngine();
var updater = new RemotePhysicsUpdater(
engine,
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new GameWindow.RemoteMotion
{
Airborne = true,
};
motion.Body.Position = new Vector3(10f, 10f, 50f);
motion.Body.Orientation = Quaternion.Identity;
motion.Body.TransientState = TransientStateFlags.Active;
motion.CellId = 0xA9B40001u;
motion.Interp.Enqueue(
new Vector3(11f, 10f, 50f),
heading: 0f,
isMovingTo: false,
currentBodyPosition: motion.Body.Position);
var entity = new WorldEntity
{
Id = 3u,
ServerGuid = 0x70000003u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = motion.Body.Position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
updater.TickHidden(motion, entity, 0.1f);
Assert.True(motion.Body.InContact);
Assert.True(motion.Body.OnWalkable);
Assert.False(motion.Airborne);
}
private static PhysicsEngine BuildBoundaryEngine()
{
static TerrainSurface FlatTerrain()
{
var heights = new byte[81];
var table = new float[256];
Array.Fill(table, 50f);
return new TerrainSurface(heights, table);
}
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock(
0xA9B4FFFFu,
FlatTerrain(),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
engine.AddLandblock(
0xAAB4FFFFu,
FlatTerrain(),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
192f,
0f);
return engine;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
using AcDream.App.Physics;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Physics;
public sealed class RemoteTeleportHookTests
{
[Fact]
public void Execute_UsesRetailOrderAndExactCancelContext()
{
var calls = new List<string>();
WeenieError cancelContext = WeenieError.None;
RemoteTeleportHook.Execute(new RemoteTeleportHookActions(
CancelMoveTo: error =>
{
cancelContext = error;
calls.Add("cancel");
},
UnStick: () => calls.Add("unstick"),
StopInterpolating: () => calls.Add("stop-interpolating"),
UnConstrain: () => calls.Add("unconstrain"),
NotifyTeleported: () => calls.Add("notify-teleported"),
ReportCollisionEnd: () => calls.Add("collision-end")));
Assert.Equal(0x3Cu, (uint)cancelContext);
Assert.Equal(
[
"cancel",
"unstick",
"stop-interpolating",
"unconstrain",
"notify-teleported",
"collision-end",
],
calls);
}
}

View file

@ -0,0 +1,194 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.App.Tests.Physics;
public sealed class RemoteTeleportPlacementTests
{
[Fact]
public void Apply_HardSnapsFullFrameAndPhysicsClock()
{
var body = new PhysicsBody
{
Orientation = Quaternion.Identity,
LastUpdateTime = 2.0,
};
body.SnapToCell(
0x01010001u,
new Vector3(3f, 4f, 5f),
new Vector3(3f, 4f, 5f));
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(210f, 220f, 12f),
0x02020123u,
IsOnGround: false),
new Vector3(18f, 28f, 12f),
orientation,
17.5,
previousContact: body.InContact,
previousOnWalkable: body.OnWalkable);
Assert.Equal(new Vector3(210f, 220f, 12f), body.Position);
Assert.Equal(0x02020123u, body.CellPosition.ObjCellId);
Assert.Equal(new Vector3(18f, 28f, 12f), body.CellPosition.Frame.Origin);
Assert.Equal(orientation, body.Orientation);
Assert.Equal(17.5, body.LastUpdateTime);
Assert.True(body.InWorld);
}
[Fact]
public void Apply_InstallsPlacementDerivedContactAndSynchronizesAirborneState()
{
var velocity = new Vector3(1f, 2f, 3f);
var body = new PhysicsBody
{
Velocity = velocity,
TransientState = TransientStateFlags.Active,
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
var contactPlane = new Plane(Vector3.UnitZ, 0f);
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body)
{
Airborne = true,
};
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(200f, 200f, 50f),
0x02020001u,
IsOnGround: true,
InContact: true,
OnWalkable: true,
ContactPlane: contactPlane,
ContactPlaneCellId: 0x02020001u),
new Vector3(8f, 8f, 50f),
Quaternion.Identity,
9.0,
previousContact: false,
previousOnWalkable: false);
Assert.Equal(Vector3.Zero, body.Velocity);
Assert.True(body.InContact);
Assert.True(body.OnWalkable);
Assert.True(body.ContactPlaneValid);
Assert.Equal(contactPlane, body.ContactPlane);
Assert.False(remote.Airborne);
}
[Fact]
public void Apply_PendingHydrationUsesPreservedSourceFlagsBeforeCollisionResponse()
{
var originalVelocity = new Vector3(-3f, 0f, 0f);
var body = new PhysicsBody
{
Velocity = originalVelocity,
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// ParkPending deliberately clears these flags while the destination
// landblock is absent. SetPositionInternal must still receive the
// grounded source edge captured before parking.
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body)
{
Airborne = true,
};
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(200f, 200f, 50f),
0x02020001u,
IsOnGround: true,
CollisionNormalValid: true,
CollisionNormal: Vector3.UnitX,
InContact: true,
OnWalkable: true,
ContactPlane: new Plane(Vector3.UnitZ, 0f),
ContactPlaneCellId: 0x02020001u),
new Vector3(8f, 8f, 50f),
Quaternion.Identity,
9.0,
previousContact: true,
previousOnWalkable: true);
// Staying on walkable ground suppresses reflection. If the parked
// false/false flags were used, the -X velocity would reflect to +X.
Assert.Equal(originalVelocity, body.Velocity);
Assert.True(body.OnWalkable);
Assert.False(remote.Airborne);
}
[Fact]
public void Apply_PendingGroundToSteepContact_RestoresSourceWalkabilityForFirstAcceleration()
{
var body = new PhysicsBody
{
Omega = new Vector3(0f, 0f, 3f),
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// The unloaded-destination parking frame cleared both bits. Retail
// restores the captured source OnWalkable bit through the first
// calc_acceleration, which clears grounded angular drift, before
// set_on_walkable installs the steep destination's false value.
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(200f, 200f, 50f),
0x02020001u,
IsOnGround: false,
InContact: true,
OnWalkable: false,
ContactPlane: new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.2f)), 0f),
ContactPlaneCellId: 0x02020001u),
new Vector3(8f, 8f, 50f),
Quaternion.Identity,
9.0,
previousContact: true,
previousOnWalkable: true);
Assert.Equal(Vector3.Zero, body.Omega);
Assert.True(body.InContact);
Assert.False(body.OnWalkable);
Assert.True(remote.Airborne);
}
[Fact]
public void Apply_RejectsMalformedPlacementWithoutMutatingBody()
{
var original = new Vector3(7f, 8f, 9f);
var body = new PhysicsBody { Position = original };
var remote = new AcDream.App.Rendering.GameWindow.RemoteMotion(body);
Assert.Throws<ArgumentOutOfRangeException>(() =>
RemoteTeleportPlacement.Apply(
remote,
body,
new ResolveResult(
new Vector3(float.NaN, 0f, 0f),
0,
IsOnGround: false,
Ok: false),
Vector3.Zero,
Quaternion.Identity,
double.NaN,
previousContact: false,
previousOnWalkable: false));
Assert.Equal(original, body.Position);
Assert.Equal(0u, body.CellPosition.ObjCellId);
}
}

View file

@ -88,6 +88,38 @@ public sealed class AttachmentUpdateOrderTests
Assert.Equal(new Vector3(100f, 2f, 0f), renderedOrigin, new Vector3ToleranceComparer()); Assert.Equal(new Vector3(100f, 2f, 0f), renderedOrigin, new Vector3ToleranceComparer());
} }
[Fact]
public void NestedDrawable_InheritsAncestorSuppressionWithoutMutatingOwnState()
{
var root = Entity(1u);
var child = Entity(2u);
var grandchild = Entity(3u);
root.IsDrawVisible = false;
EquippedChildRenderController.ApplyParentDrawVisibility(child, root);
EquippedChildRenderController.ApplyParentDrawVisibility(grandchild, child);
Assert.True(child.IsDrawVisible);
Assert.False(child.IsAncestorDrawVisible);
Assert.True(grandchild.IsDrawVisible);
Assert.False(grandchild.IsAncestorDrawVisible);
root.IsDrawVisible = true;
EquippedChildRenderController.ApplyParentDrawVisibility(child, root);
EquippedChildRenderController.ApplyParentDrawVisibility(grandchild, child);
Assert.True(child.IsAncestorDrawVisible);
Assert.True(grandchild.IsAncestorDrawVisible);
}
private static AcDream.Core.World.WorldEntity Entity(uint id) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
};
[Fact] [Fact]
public void AncestorTeardownCollectsEntireAttachmentSubtreePostOrder() public void AncestorTeardownCollectsEntireAttachmentSubtreePostOrder()
{ {

View file

@ -0,0 +1,65 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Tests.Rendering;
public sealed class EntityPhysicsHostTests
{
[Fact]
public void NotifyHidden_FansNonOkStatusAndClearsWatcherSubscription()
{
const uint watcherId = 0x50000001u;
const uint targetId = 0x70000001u;
var hosts = new Dictionary<uint, IPhysicsObjHost>();
var watcherUpdates = new List<TargetInfo>();
EntityPhysicsHost watcher = CreateHost(
watcherId,
hosts,
watcherUpdates.Add);
EntityPhysicsHost target = CreateHost(targetId, hosts, _ => { });
hosts.Add(watcherId, watcher);
hosts.Add(targetId, target);
watcher.SetTarget(0u, targetId, 1f, 0.1);
watcherUpdates.Clear();
target.NotifyHidden();
TargetInfo hidden = Assert.Single(watcherUpdates);
Assert.Equal(TargetStatus.ExitWorld, hidden.Status);
Assert.Null(watcher.TargetManager.TargetInfo);
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(watcherId));
watcherUpdates.Clear();
watcher.SetTarget(0u, targetId, 1f, 0.1);
TargetInfo restored = Assert.Single(watcherUpdates);
Assert.Equal(TargetStatus.Ok, restored.Status);
Assert.Equal(targetId, restored.ObjectId);
Assert.True(target.TargetManager.VoyeurTable.ContainsKey(watcherId));
}
private static EntityPhysicsHost CreateHost(
uint id,
IReadOnlyDictionary<uint, IPhysicsObjHost> hosts,
Action<TargetInfo> update)
{
return new EntityPhysicsHost(
id,
getPosition: () => new Position(
0x01010001u,
new Vector3(id == 0x50000001u ? 0f : 2f, 0f, 0f),
Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 1.0,
physicsTimerTime: () => 1.0,
getObjectA: objectId => hosts.GetValueOrDefault(objectId),
handleUpdateTarget: update,
interruptCurrentMovement: () => { });
}
}

View file

@ -166,6 +166,28 @@ public sealed class EntityEffectControllerTests
Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid); Assert.DoesNotContain(fixture.Sink.Calls, call => call.EntityId == Guid);
} }
[Fact]
public void PreparedOwner_DoesNotReplayPendingPacketsUntilConstructionBarrierOpens()
{
var fixture = new Fixture();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
WorldSession.EntitySpawn spawn = Spawn(Guid, 1);
fixture.Runtime.RegisterLiveEntity(spawn);
fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile());
fixture.Runtime.MaterializeLiveEntity(
Guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, Guid));
Assert.True(fixture.Controller.PrepareLiveEntityOwner(Guid));
Assert.Equal(1, fixture.Controller.PendingPacketCount);
Assert.Equal(0, fixture.Runner.ActiveScriptCount);
Assert.True(fixture.Controller.ReplayPendingForLiveEntity(Guid));
Assert.Equal(0, fixture.Controller.PendingPacketCount);
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
}
[Fact] [Fact]
public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately() public void ReadyOwnerReceivesDirectAndTypedPacketsImmediately()
{ {

View file

@ -96,6 +96,46 @@ public sealed class RadarSnapshotProviderTests
Assert.True(snapshot.UiLocked); Assert.True(snapshot.UiLocked);
} }
[Fact]
public void BuildSnapshot_UsesCanonicalPlayerWhileHiddenTargetsStayExcluded()
{
const uint player = 20u;
const uint hiddenTarget = 21u;
var objects = new ClientObjectTable();
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
objects.Ingest(Weenie(hiddenTarget, "Hidden target", ItemType.Creature) with
{
RadarBehavior = (byte)RadarBehavior.ShowAlways,
});
var canonical = new Dictionary<uint, WorldEntity>
{
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
[hiddenTarget] = Entity(hiddenTarget, new Vector3(5f, 0f, 0f), Quaternion.Identity),
};
var interactionVisible = new Dictionary<uint, WorldEntity>();
var spawns = new Dictionary<uint, WorldSession.EntitySpawn>
{
[player] = Spawn(player),
[hiddenTarget] = Spawn(hiddenTarget),
};
var provider = new RadarSnapshotProvider(
objects,
interactionVisible,
spawns,
playerGuid: () => player,
playerYawRadians: () => 0f,
playerCellId: () => 0xA9B40001u,
selectedGuid: () => hiddenTarget,
coordinatesOnRadar: () => true,
uiLocked: () => false,
playerEntities: canonical);
var snapshot = provider.BuildSnapshot();
Assert.NotNull(snapshot.CoordinatesText);
Assert.Empty(snapshot.Blips);
}
private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new() private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new()
{ {
Id = guid, Id = guid,

View file

@ -180,9 +180,10 @@ public sealed class InboundPhysicsStateControllerTests
liveVelocity, liveVelocity,
out PositionTimestampDisposition normalDisposition, out PositionTimestampDisposition normalDisposition,
out WorldSession.EntitySpawn normal, out WorldSession.EntitySpawn normal,
out _)); out AcceptedPhysicsTimestamps normalTimestamps));
Assert.Equal(PositionTimestampDisposition.Apply, normalDisposition); Assert.Equal(PositionTimestampDisposition.Apply, normalDisposition);
Assert.Equal(liveVelocity, normal.Physics!.Value.Velocity); Assert.Equal(liveVelocity, normal.Physics!.Value.Velocity);
Assert.False(normalTimestamps.TeleportAdvanced);
Assert.True(controller.TryApplyPosition( Assert.True(controller.TryApplyPosition(
new WorldSession.EntityPositionUpdate( new WorldSession.EntityPositionUpdate(
@ -200,9 +201,10 @@ public sealed class InboundPhysicsStateControllerTests
liveVelocity, liveVelocity,
out PositionTimestampDisposition teleportDisposition, out PositionTimestampDisposition teleportDisposition,
out WorldSession.EntitySpawn teleported, out WorldSession.EntitySpawn teleported,
out _)); out AcceptedPhysicsTimestamps teleportTimestamps));
Assert.Equal(PositionTimestampDisposition.Apply, teleportDisposition); Assert.Equal(PositionTimestampDisposition.Apply, teleportDisposition);
Assert.Equal(Vector3.Zero, teleported.Physics!.Value.Velocity); Assert.Equal(Vector3.Zero, teleported.Physics!.Value.Velocity);
Assert.True(teleportTimestamps.TeleportAdvanced);
} }
[Fact] [Fact]

View file

@ -0,0 +1,273 @@
using System.Numerics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.World;
public sealed class LiveEntityPresentationControllerTests
{
[Fact]
public void InitialVisibleObject_DoesNotPlayUnHide()
{
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
Assert.Empty(fixture.TypedPlays);
Assert.True(fixture.Entity.IsDrawVisible);
Assert.Equal(1, fixture.Shadows.TotalRegistered);
}
[Fact]
public void HiddenThenVisible_PlaysTypedEffectsMutatesChildrenAndRestoresShadow()
{
Fixture fixture = new(
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions);
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
Assert.Equal(
[(fixture.Entity.Id, LiveEntityPresentationController.HiddenScriptType, 1f)],
fixture.TypedPlays);
Assert.Equal([(Fixture.Guid, true)], fixture.ChildNoDraw);
Assert.Equal([Fixture.Guid], fixture.InvalidTargets);
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.False(fixture.Entity.IsDrawVisible);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(Fixture.Guid, 0u, 1, 2),
out _,
out _));
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
Assert.Equal(
[
(fixture.Entity.Id, LiveEntityPresentationController.HiddenScriptType, 1f),
(fixture.Entity.Id, LiveEntityPresentationController.UnHideScriptType, 1f),
],
fixture.TypedPlays);
Assert.Equal([(Fixture.Guid, true), (Fixture.Guid, false)], fixture.ChildNoDraw);
Assert.Equal(1, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Entity.IsDrawVisible);
Assert.Same(fixture.Entity, Assert.Single(fixture.Runtime.WorldEntities).Value);
}
[Fact]
public void IdenticalOrStaleState_DoesNotReplayHiddenEffect()
{
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
fixture.Controller.OnLiveEntityReady(Fixture.Guid);
var hidden = new SetState.Parsed(
Fixture.Guid,
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
1,
2);
Assert.True(fixture.Runtime.TryApplyState(hidden, out _, out _));
fixture.Controller.OnStateAccepted(Fixture.Guid);
Assert.False(fixture.Runtime.TryApplyState(hidden, out _, out _));
fixture.Controller.OnStateAccepted(Fixture.Guid);
Assert.Single(fixture.TypedPlays);
Assert.Equal(LiveEntityPresentationController.HiddenScriptType,
fixture.TypedPlays[0].Type);
}
[Fact]
public void UnHideWhileLandblockPending_RestoresShadowWhenProjectionReturns()
{
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
fixture.Controller.OnLiveEntityReady(Fixture.Guid);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(
Fixture.Guid,
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
1,
2),
out _,
out _));
fixture.Controller.OnStateAccepted(Fixture.Guid);
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Runtime.RebucketLiveEntity(Fixture.Guid, 0x02020001u));
Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(Fixture.Guid, 0u, 1, 3),
out _,
out _));
fixture.Controller.OnStateAccepted(Fixture.Guid);
Assert.Equal(0, fixture.Shadows.TotalRegistered);
fixture.Spatial.AddLandblock(new LoadedLandblock(
0x0202FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.True(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid));
Assert.Equal(1, fixture.Shadows.TotalRegistered);
}
[Fact]
public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset()
{
Fixture fixture = new(PhysicsStateFlags.ReportCollisions);
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
Assert.True(fixture.Controller.BeginAuthoritativePlacement(Fixture.Guid, 1));
Assert.True(fixture.Controller.HasActivePlacement(Fixture.Guid));
Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out LiveEntityRecord oldRecord));
fixture.Controller.Forget(oldRecord);
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Fixture.Guid, 1),
isLocalPlayer: false));
Assert.False(fixture.Controller.HasActivePlacement(Fixture.Guid));
fixture.Runtime.RegisterLiveEntity(Fixture.Spawn(
PhysicsStateFlags.ReportCollisions,
instanceSequence: 2));
fixture.Runtime.MaterializeLiveEntity(
Fixture.Guid,
0x01010001u,
id => new WorldEntity
{
Id = id,
ServerGuid = Fixture.Guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 10f, 5f),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
});
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
Assert.False(fixture.Controller.CompleteAuthoritativePlacement(
Fixture.Guid,
generation: 1,
deferShadowRestore: true));
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
Assert.True(fixture.Controller.BeginAuthoritativePlacement(Fixture.Guid, 2));
Assert.True(fixture.Controller.HasActivePlacement(Fixture.Guid));
fixture.Controller.Clear();
Assert.False(fixture.Controller.HasActivePlacement(Fixture.Guid));
Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid));
}
private sealed class Fixture
{
public const uint Guid = 0x70000051u;
public List<(uint Owner, uint Type, float Intensity)> TypedPlays { get; } = [];
public List<(uint Parent, bool NoDraw)> ChildNoDraw { get; } = [];
public List<uint> InvalidTargets { get; } = [];
public GpuWorldState Spatial { get; }
public LiveEntityRuntime Runtime { get; }
public ShadowObjectRegistry Shadows { get; } = new();
public WorldEntity Entity { get; }
public LiveEntityPresentationController Controller { get; }
public Fixture(PhysicsStateFlags initialState)
{
Spatial = new GpuWorldState();
Spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
Runtime = new LiveEntityRuntime(
Spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
WorldSession.EntitySpawn spawn = Spawn(initialState);
Runtime.RegisterLiveEntity(spawn);
Entity = Runtime.MaterializeLiveEntity(
Guid,
0x01010001u,
id => new WorldEntity
{
Id = id,
ServerGuid = Guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 10f, 5f),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
})!;
Shadows.Register(
Entity.Id,
0x01000001u,
Entity.Position,
Entity.Rotation,
1f,
0f,
0f,
0x01010000u,
state: (uint)initialState,
seedCellId: 0x01010001u);
Controller = new LiveEntityPresentationController(
Runtime,
Shadows,
(owner, type, intensity) =>
{
TypedPlays.Add((owner, type, intensity));
return true;
},
(parent, noDraw) => ChildNoDraw.Add((parent, noDraw)),
InvalidTargets.Add,
() => (1, 1));
}
internal static WorldSession.EntitySpawn Spawn(
PhysicsStateFlags state,
ushort instanceSequence = 1)
{
var position = new CreateObject.ServerPosition(
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
var physics = new PhysicsSpawnData(
RawState: (uint)state,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
Guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
MotionTableId: 0x09000001u,
PhysicsState: (uint)state,
InstanceSequence: instanceSequence,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
}
}

View file

@ -29,6 +29,11 @@ public sealed class LiveEntityRuntimeTests
private sealed class EffectProfile : ILiveEntityEffectProfile { } private sealed class EffectProfile : ILiveEntityEffectProfile { }
private sealed class RemoteMotionRuntime : ILiveEntityRemoteMotionRuntime
{
public PhysicsBody Body { get; } = new();
}
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
{ {
public int RegisterCount { get; private set; } public int RegisterCount { get; private set; }
@ -73,6 +78,8 @@ public sealed class LiveEntityRuntimeTests
spawn.Position!.Value.LandblockId, spawn.Position!.Value.LandblockId,
id => Entity(id, spawn.Guid)); id => Entity(id, spawn.Guid));
runtime.SetAnimationRuntime(spawn.Guid, new AnimationRuntime(entity!)); runtime.SetAnimationRuntime(spawn.Guid, new AnimationRuntime(entity!));
var visibilityEdges = new List<bool>();
runtime.ProjectionVisibilityChanged += (_, visible) => visibilityEdges.Add(visible);
Assert.True(registered.LogicalRegistrationCreated); Assert.True(registered.LogicalRegistrationCreated);
Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.RegisterCount);
@ -90,6 +97,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Single(spatial.Entities); Assert.Single(spatial.Entities);
Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.RegisterCount);
Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _)); Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _));
Assert.Equal(new[] { false, true }, visibilityEdges);
} }
[Fact] [Fact]
@ -296,6 +304,7 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved)); Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved));
Assert.Same(attached, resolved); Assert.Same(attached, resolved);
Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.RegisterCount);
attached.IsAncestorDrawVisible = false;
WorldEntity same = runtime.MaterializeLiveEntity( WorldEntity same = runtime.MaterializeLiveEntity(
spawn.Guid, spawn.Guid,
@ -304,6 +313,7 @@ public sealed class LiveEntityRuntimeTests
LiveEntityProjectionKind.World)!; LiveEntityProjectionKind.World)!;
Assert.Same(attached, same); Assert.Same(attached, same);
Assert.True(same.IsAncestorDrawVisible);
Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value); Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value);
Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid));
@ -500,6 +510,210 @@ public sealed class LiveEntityRuntimeTests
Assert.True(runtime.ShouldAdvanceRootRuntime(guid)); Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
} }
[Fact]
public void PhysicsStateAndRemoteBodyStaySynchronizedAcrossEitherArrivalOrder()
{
const uint stateBeforeBindGuid = 0x70000037u;
const uint bindBeforeStateGuid = 0x70000038u;
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
runtime.RegisterLiveEntity(Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u));
runtime.RegisterLiveEntity(Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u));
PhysicsStateFlags firstState = PhysicsStateFlags.Hidden
| PhysicsStateFlags.Gravity
| PhysicsStateFlags.ReportCollisions;
Assert.True(runtime.TryApplyState(
new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2),
out _));
var lateBody = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(stateBeforeBindGuid, lateBody);
var earlyBody = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(bindBeforeStateGuid, earlyBody);
PhysicsStateFlags secondState = PhysicsStateFlags.Static
| PhysicsStateFlags.Ethereal
| PhysicsStateFlags.NoDraw;
Assert.True(runtime.TryApplyState(
new SetState.Parsed(bindBeforeStateGuid, (uint)secondState, 1, 2),
out _));
Assert.Equal((firstState & ~PhysicsStateFlags.ReportCollisions)
| PhysicsStateFlags.IgnoreCollisions,
lateBody.Body.State);
Assert.Equal(secondState, earlyBody.Body.State);
}
[Fact]
public void ParentDrivenChildNoDrawKeepsBoundBodyInCanonicalState()
{
const uint guid = 0x70000039u;
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
var remote = new RemoteMotionRuntime();
runtime.SetRemoteMotionRuntime(guid, remote);
Assert.True(runtime.SetAttachedChildNoDraw(guid, true));
Assert.True(remote.Body.State.HasFlag(PhysicsStateFlags.NoDraw));
Assert.True(runtime.SetAttachedChildNoDraw(guid, false));
Assert.False(remote.Body.State.HasFlag(PhysicsStateFlags.NoDraw));
}
[Fact]
public void InitiallyVisibleCreate_DoesNotManufactureUnHideTransition()
{
const uint guid = 0x70000040u;
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.True(record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition));
Assert.Equal(RetailHiddenTransition.None, transition.HiddenTransition);
Assert.Equal(PhysicsStateFlags.ReportCollisions, record.FinalPhysicsState);
}
[Fact]
public void HiddenCreate_SuppressesMeshInteractionButRetainsNarrowObjectTick()
{
const uint guid = 0x70000041u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(
guid,
1,
1,
0x01010001u,
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions));
WorldEntity entity = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
Assert.False(entity.IsDrawVisible);
Assert.Empty(runtime.WorldEntities);
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Single(spatial.Entities);
Assert.True(runtime.ShouldAdvanceRootRuntime(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.Equal(PhysicsStateFlags.None,
record.FinalPhysicsState & PhysicsStateFlags.ReportCollisions);
Assert.NotEqual(PhysicsStateFlags.None,
record.FinalPhysicsState & PhysicsStateFlags.IgnoreCollisions);
}
[Fact]
public void HiddenThenVisibleState_RestoresSameMeshAndInteractionIdentity()
{
const uint guid = 0x70000042u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
WorldEntity entity = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
Assert.True(runtime.TryApplyState(new SetState.Parsed(
guid,
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions),
1,
2), out _, out RetailPhysicsStateTransition hidden));
Assert.Equal(RetailHiddenTransition.BecameHidden, hidden.HiddenTransition);
Assert.False(entity.IsDrawVisible);
Assert.Empty(runtime.WorldEntities);
Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _));
Assert.True(runtime.TryApplyState(new SetState.Parsed(
guid,
0u,
1,
3), out _, out RetailPhysicsStateTransition visible));
Assert.Equal(RetailHiddenTransition.BecameVisible, visible.HiddenTransition);
Assert.True(entity.IsDrawVisible);
Assert.True(runtime.TryGetInteractionEligibleEntity(guid, out var eligible));
Assert.Same(entity, eligible);
Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(entity, Assert.Single(spatial.Entities));
}
[Fact]
public void PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp()
{
const uint guid = 0x70000043u;
var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
Assert.True(runtime.TryApplyPickup(
new PickupEvent.Parsed(guid, 1, 2),
out _));
var update = new WorldSession.EntityPositionUpdate(
guid,
new CreateObject.ServerPosition(
0x01010002u, 12f, 13f, 5f, 1f, 0f, 0f, 0f),
null,
null,
true,
1,
3,
0,
0);
Assert.True(runtime.TryApplyPosition(
update,
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out PositionTimestampDisposition disposition,
out _,
out AcceptedPhysicsTimestamps timestamps));
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
Assert.False(timestamps.TeleportAdvanced);
Assert.True(timestamps.TeleportHookRequired);
}
[Fact]
public void PositionFromPendingProjection_RequiresTeleportHookWithEqualTeleportStamp()
{
const uint guid = 0x70000044u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
spatial.RemoveLandblock(0x0101FFFFu);
Assert.True(runtime.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
guid,
new CreateObject.ServerPosition(
0x01010002u, 12f, 13f, 5f, 1f, 0f, 0f, 0f),
null,
null,
true,
1,
2,
0,
0),
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out PositionTimestampDisposition disposition,
out _,
out AcceptedPhysicsTimestamps timestamps));
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
Assert.False(timestamps.TeleportAdvanced);
Assert.True(timestamps.TeleportHookRequired);
}
[Fact] [Fact]
public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity() public void LandblockUnload_HidesTopLevelViewAndReloadRestoresSameIdentity()
{ {
@ -792,14 +1006,15 @@ public sealed class LiveEntityRuntimeTests
uint guid, uint guid,
ushort instance, ushort instance,
ushort positionSequence, ushort positionSequence,
uint cell) uint cell,
PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions)
{ {
var position = new CreateObject.ServerPosition( var position = new CreateObject.ServerPosition(
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps( var timestamps = new PhysicsTimestamps(
positionSequence, 1, 1, 1, 0, 1, 0, 1, instance); positionSequence, 1, 1, 1, 0, 1, 0, 1, instance);
var physics = new PhysicsSpawnData( var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions, RawState: (uint)state,
Position: position, Position: position,
Movement: null, Movement: null,
AnimationFrame: null, AnimationFrame: null,
@ -832,7 +1047,7 @@ public sealed class LiveEntityRuntimeTests
null, null,
null, null,
0x09000001u, 0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, PhysicsState: (uint)state,
InstanceSequence: instance, InstanceSequence: instance,
MovementSequence: 1, MovementSequence: 1,
ServerControlSequence: 1, ServerControlSequence: 1,

View file

@ -145,4 +145,62 @@ public class HandleAllCollisionsTests
// dot = -5 < 0 → k = -(-5*1.05) = 5.25 → v.z = -5 + 5.25 = 0.25 (tiny bounce) // dot = -5 < 0 → k = -(-5*1.05) = 5.25 → v.z = -5 + 5.25 = 0.25 (tiny bounce)
Assert.Equal(0.25f, b.Velocity.Z, precision: 3); Assert.Equal(0.25f, b.Velocity.Z, precision: 3);
} }
[Fact]
public void SetPositionTransition_HiddenLandingCallbackRunsBeforeCollisionResponse()
{
var body = Airborne(Vector3.Zero);
int hitGroundCalls = 0;
PhysicsObjUpdate.CommitSetPositionTransition(
body,
inContact: true,
onWalkable: true,
collisionNormalValid: true,
collisionNormal: Vector3.UnitZ,
previousContact: false,
previousOnWalkable: false,
hitGround: () =>
{
hitGroundCalls++;
body.Velocity = new Vector3(0f, 0f, -4f);
});
Assert.Equal(1, hitGroundCalls);
Assert.True(body.InContact);
Assert.True(body.OnWalkable);
// Callback -4 then retail elasticity 0.05 reflection => +0.2.
// Running the callback after collision response would leave -4.
Assert.Equal(0.2f, body.Velocity.Z, precision: 3);
}
[Fact]
public void SetPositionTransition_HiddenWalkOffCallbackRunsBeforeCollisionResponse()
{
var body = Airborne(Vector3.Zero);
body.TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable;
int leaveGroundCalls = 0;
PhysicsObjUpdate.CommitSetPositionTransition(
body,
inContact: false,
onWalkable: false,
collisionNormalValid: true,
collisionNormal: -Vector3.UnitX,
previousContact: true,
previousOnWalkable: true,
leaveGround: () =>
{
leaveGroundCalls++;
body.Velocity = new Vector3(3f, 0f, 0f);
});
Assert.Equal(1, leaveGroundCalls);
Assert.False(body.InContact);
Assert.False(body.OnWalkable);
// Callback +3 into a -X normal then reflection => -0.15.
// Running the callback after response would leave +3.
Assert.Equal(-0.15f, body.Velocity.X, precision: 3);
}
} }

View file

@ -50,6 +50,9 @@ public sealed class InitialPlacementOverlapTests
movingEntityId: PlayerId); movingEntityId: PlayerId);
Assert.True(result.Ok); Assert.True(result.Ok);
Assert.True(result.InContact);
Assert.True(result.OnWalkable);
Assert.Equal(Cell, result.ContactPlaneCellId);
Assert.NotEqual(savedFeet, result.Position); Assert.NotEqual(savedFeet, result.Position);
float centerDistance = Vector3.Distance( float centerDistance = Vector3.Distance(
result.Position + new Vector3(0f, 0f, Radius), result.Position + new Vector3(0f, 0f, Radius),

View file

@ -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);
}
}

View file

@ -102,6 +102,46 @@ public sealed class WbDrawDispatcherBucketingTests
Assert.Empty(result.ToDraw); Assert.Empty(result.ToDraw);
} }
[Fact]
public void WalkEntities_NoDrawHiddenOrSuppressedAncestor_SkipsMeshWithoutRemovingEntity()
{
WorldEntity suppressed = MakeEntityWithMesh(7u, Vector3.Zero);
suppressed.IsDrawVisible = false;
var entities = new[] { suppressed };
var entries = new[]
{
new WbDrawDispatcher.LandblockEntry(
0xAAAA_FFFFu,
new Vector3(-10f),
new Vector3(10f),
entities,
BuildById(entities)),
};
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: null,
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: new HashSet<uint> { suppressed.Id });
Assert.Equal(0, result.EntitiesWalked);
Assert.Empty(result.ToDraw);
Assert.Same(suppressed, Assert.Single(entries[0].Entities));
suppressed.IsDrawVisible = true;
suppressed.IsAncestorDrawVisible = false;
result = WbDrawDispatcher.WalkEntities(
entries,
frustum: null,
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: new HashSet<uint> { suppressed.Id });
Assert.Equal(0, result.EntitiesWalked);
Assert.Empty(result.ToDraw);
}
[Fact] [Fact]
public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities() public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities()
{ {