From 69a2ca0c6db9b86392fd0f1bae7d83de6e715cf5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 16:17:03 +0200 Subject: [PATCH] refactor(world): extract live projection mechanics Move appearance rebinding, collision construction, default-pose resolution, local shadow ownership, and exact leave-world presentation into focused owners. Preserve retail parent ordering with staged validation, committed recovery, recursive attached-subtree withdrawal, and retryable exact teardown across parent, pickup, position, unwield, and pose-loss edges. Co-Authored-By: OpenAI Codex --- .../retail-divergence-register.md | 6 +- .../Physics/LiveEntityCollisionBuilder.cs | 231 ++++ .../Physics/LiveEntityDefaultPoseResolver.cs | 59 + .../Physics/LocalPlayerShadowState.cs | 19 + .../EquippedChildRenderController.cs | 905 ++++++++++++- src/AcDream.App/Rendering/GameWindow.cs | 452 ++----- .../Rendering/LiveEntityAppearanceBinding.cs | 127 ++ .../World/InboundPhysicsStateController.cs | 57 +- ...iveEntityProjectionWithdrawalController.cs | 175 +++ src/AcDream.App/World/LiveEntityRuntime.cs | 69 +- .../World/ParentAttachmentState.cs | 148 ++- .../Physics/ShadowObjectRegistry.cs | 86 ++ .../Physics/ShadowShapeBuilder.cs | 16 +- src/AcDream.Core/Physics/TransitionTypes.cs | 4 +- .../LiveEntityCollisionBuilderTests.cs | 358 +++++ .../EquippedChildProjectionWithdrawalTests.cs | 1151 +++++++++++++++++ .../Rendering/LiveAppearanceAnimationTests.cs | 133 +- .../LiveEntityAnimationPresenterTests.cs | 4 +- .../Wb/EntitySpawnAdapterLifetimeTests.cs | 72 ++ .../GameWindowLiveEntityCompositionTests.cs | 51 + .../InboundPhysicsStateControllerTests.cs | 14 +- ...tityProjectionWithdrawalControllerTests.cs | 289 +++++ .../World/LiveEntityRuntimeTests.cs | 6 +- .../World/ParentAttachmentStateTests.cs | 37 +- .../Physics/ShadowObjectRegistryTests.cs | 113 ++ .../Physics/ShadowShapeBuilderTests.cs | 20 + 26 files changed, 4172 insertions(+), 430 deletions(-) create mode 100644 src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs create mode 100644 src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs create mode 100644 src/AcDream.App/Physics/LocalPlayerShadowState.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs create mode 100644 src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs create mode 100644 tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs create mode 100644 tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 781d88d8..8258839f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -132,7 +132,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE | -| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | +| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | | AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | | ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` | | AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) | @@ -184,7 +184,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks), moving to `LiveEntityNetworkUpdateController` in Slice 4 | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | | AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) | -| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | +| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | | AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection + authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit | @@ -253,7 +253,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 | | TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame | | TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` | -| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | +| ~~TS-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-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | | TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | | TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and 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/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Rendering/GameWindow.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | diff --git a/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs new file mode 100644 index 00000000..8da45062 --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs @@ -0,0 +1,231 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Physics; + +/// +/// Immutable input to the canonical shadow registry for one live object. +/// Building is DAT/physics-only and therefore testable without a window or GL +/// context; registration remains one explicit commit. +/// +internal sealed record LiveEntityCollisionRegistration( + uint EntityId, + uint SourceId, + Vector3 EntityWorldPosition, + Quaternion EntityWorldRotation, + IReadOnlyList Shapes, + uint State, + EntityCollisionFlags Flags, + float WorldOffsetX, + float WorldOffsetY, + uint LandblockId, + uint SeedCellId); + +/// +/// Ports the live-object collision-shape policy used by +/// CPartArray::FindObjCollisions: CylSpheres before Spheres, every +/// physics-BSP part, and the established Setup-radius fallback for the +/// remaining ACE prop data. +/// +internal sealed class LiveEntityCollisionBuilder +{ + private readonly Func _hasPhysicsBsp; + private readonly Func _physicsBspRadius; + private readonly LiveEntityDefaultPoseResolver _defaultPose; + + public LiveEntityCollisionBuilder( + PhysicsDataCache physicsData, + LiveEntityDefaultPoseResolver defaultPose) + : this( + id => physicsData.GetGfxObj(id)?.BSP?.Root is not null, + id => physicsData.GetGfxObj(id)?.BoundingSphere?.Radius, + defaultPose) + { + ArgumentNullException.ThrowIfNull(physicsData); + } + + internal LiveEntityCollisionBuilder( + Func hasPhysicsBsp, + Func physicsBspRadius, + LiveEntityDefaultPoseResolver defaultPose) + { + _hasPhysicsBsp = hasPhysicsBsp + ?? throw new ArgumentNullException(nameof(hasPhysicsBsp)); + _physicsBspRadius = physicsBspRadius + ?? throw new ArgumentNullException(nameof(physicsBspRadius)); + _defaultPose = defaultPose + ?? throw new ArgumentNullException(nameof(defaultPose)); + } + + /// + /// Resolves collision slot zero for every post-AnimPartChanged identity. + /// This intentionally has no visual-LOD option or Setup-type input. + /// + public static uint[] ResolveEffectivePartIdentities( + IReadOnlyList postAnimPartGfxObjIds, + Func resolveSlotZero) + { + ArgumentNullException.ThrowIfNull(postAnimPartGfxObjIds); + ArgumentNullException.ThrowIfNull(resolveSlotZero); + var result = new uint[postAnimPartGfxObjIds.Count]; + for (int i = 0; i < result.Length; i++) + result[i] = resolveSlotZero(postAnimPartGfxObjIds[i]); + return result; + } + + public LiveEntityCollisionRegistration? Build( + WorldEntity entity, + Setup setup, + IReadOnlyList effectivePartGfxObjIds, + WorldSession.EntitySpawn spawn, + LiveEntityRecord exactRecord, + Vector3 worldOrigin, + bool retainEmptyPayload = false) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(setup); + ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds); + ArgumentNullException.ThrowIfNull(exactRecord); + if (spawn.Position is not { } position) + return null; + if (spawn.Guid != exactRecord.ServerGuid + || spawn.InstanceSequence != exactRecord.Generation + || !ReferenceEquals(exactRecord.WorldEntity, entity)) + { + throw new InvalidOperationException( + "Live collision construction requires the exact materialized record."); + } + + float scale = spawn.ObjScale ?? 1f; + IReadOnlyList? defaultPose = _defaultPose.Resolve( + spawn.MotionTableId ?? 0u, + setup.Parts.Count); + IReadOnlyList raw = ShadowShapeBuilder.FromSetup( + setup, + scale, + _hasPhysicsBsp, + partPoseOverride: defaultPose, + effectivePartGfxObjIds: effectivePartGfxObjIds); + var shapes = new List(raw.Count); + foreach (ShadowShape shape in raw) + { + if (shape.CollisionType == ShadowCollisionType.BSP) + { + float radius = (_physicsBspRadius(shape.GfxObjId) ?? 2f) * scale; + shapes.Add(shape with { Radius = radius }); + } + else + { + shapes.Add(shape); + } + } + + if (shapes.Count == 0 && setup.Radius > 0.0001f) + { + shapes.Add(new ShadowShape( + GfxObjId: 0u, + LocalPosition: Vector3.Zero, + LocalRotation: Quaternion.Identity, + Scale: scale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: setup.Radius * scale, + CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale)); + } + + if (shapes.Count == 0 && !retainEmptyPayload) + return null; + + EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie; + if (spawn.ObjectDescriptionFlags is { } descriptionFlags) + flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags); + if (spawn.ItemType == (uint)ItemType.Creature) + flags |= EntityCollisionFlags.IsCreature; + + return new LiveEntityCollisionRegistration( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + shapes, + (uint)exactRecord.FinalPhysicsState, + flags, + worldOrigin.X, + worldOrigin.Y, + position.LandblockId, + position.LandblockId); + } + + public static void Register( + ShadowObjectRegistry registry, + LiveEntityCollisionRegistration registration) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(registration); + registry.RegisterMultiPart( + registration.EntityId, + registration.EntityWorldPosition, + registration.EntityWorldRotation, + registration.Shapes, + registration.State, + registration.Flags, + registration.WorldOffsetX, + registration.WorldOffsetY, + registration.LandblockId, + registration.SeedCellId, + isStatic: false); + + if (!PhysicsDiagnostics.ProbeBuildingEnabled) + return; + + int cylinders = 0; + int bsps = 0; + foreach (ShadowShape shape in registration.Shapes) + { + if (shape.CollisionType == ShadowCollisionType.Cylinder) + cylinders++; + else + bsps++; + } + Console.WriteLine(FormattableString.Invariant( + $"[entity-source] id=0x{registration.EntityId:X8} entityId=0x{registration.EntityId:X8} src=0x{registration.SourceId:X8} gfxObj=0x{registration.SourceId:X8} lb=0x{registration.LandblockId:X8} shapes=cyl{cylinders}+bsp{bsps} note=server-spawn-root state=0x{registration.State:X8} flags={registration.Flags}")); + } + + /// + /// Commits a retail ObjDesc part-array replacement. A shapeless result + /// removes the prior payload; a shaped result replaces it without making + /// a Hidden, parented, or cell-less object collide in world cells. + /// + public static void ReconcileAppearance( + ShadowObjectRegistry registry, + uint entityId, + LiveEntityCollisionRegistration? registration, + bool suspendIfNew) + { + ArgumentNullException.ThrowIfNull(registry); + if (registration is null) + return; + + if (registration.EntityId != entityId) + throw new InvalidOperationException("Collision replacement belongs to a different live entity."); + + registry.ReplaceMultiPartPayload( + registration.EntityId, + registration.EntityWorldPosition, + registration.EntityWorldRotation, + registration.Shapes, + registration.State, + registration.Flags, + registration.WorldOffsetX, + registration.WorldOffsetY, + registration.LandblockId, + registration.SeedCellId, + isStatic: false, + suspendIfNew); + } +} diff --git a/src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs b/src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs new file mode 100644 index 00000000..40d9a74f --- /dev/null +++ b/src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs @@ -0,0 +1,59 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Physics; + +/// +/// Resolves the motion table's authored default-state part pose used by a +/// live object's collision PartArray. Retail keeps the collision parts in the +/// same current pose as the visual PartArray; an unresolved table falls back +/// to Setup placement frames in . +/// The retained default-pose snapshot approximation is tracked as AP-84 in +/// the retail divergence register until live per-frame collision poses land. +/// +internal sealed class LiveEntityDefaultPoseResolver +{ + private readonly Func _loadMotionTable; + private readonly IAnimationLoader _animationLoader; + private readonly bool _dumpMotion; + + public LiveEntityDefaultPoseResolver( + Func loadMotionTable, + IAnimationLoader animationLoader, + bool dumpMotion) + { + _loadMotionTable = loadMotionTable + ?? throw new ArgumentNullException(nameof(loadMotionTable)); + _animationLoader = animationLoader + ?? throw new ArgumentNullException(nameof(animationLoader)); + _dumpMotion = dumpMotion; + } + + public IReadOnlyList? Resolve(uint motionTableId, int partCount) + { + if (motionTableId == 0u || partCount == 0) + return null; + + MotionTable? motionTable = _loadMotionTable(motionTableId); + if (motionTable is null) + return null; + + IReadOnlyList? pose = MotionTablePose.DefaultStatePartFrames( + motionTable, + _animationLoader.LoadAnimation); + + if (_dumpMotion) + { + string description = pose is null + ? "null->placement-fallback" + : FormattableString.Invariant( + $"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})"); + Console.WriteLine(FormattableString.Invariant( + $"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {description}")); + } + + return pose; + } +} diff --git a/src/AcDream.App/Physics/LocalPlayerShadowState.cs b/src/AcDream.App/Physics/LocalPlayerShadowState.cs new file mode 100644 index 00000000..777384da --- /dev/null +++ b/src/AcDream.App/Physics/LocalPlayerShadowState.cs @@ -0,0 +1,19 @@ +using System.Numerics; + +namespace AcDream.App.Physics; + +/// Session-scoped cache of the local player's last published shadow pose. +internal sealed class LocalPlayerShadowState +{ + public readonly record struct Snapshot( + Vector3 Position, + Quaternion Orientation, + uint CellId); + + public Snapshot? Current { get; private set; } + + public void Set(Vector3 position, Quaternion orientation, uint cellId) => + Current = new Snapshot(position, orientation, cellId); + + public void Clear() => Current = null; +} diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 1e63cae8..87adf4fb 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -29,6 +29,8 @@ public sealed class EquippedChildRenderController : IDisposable private readonly ClientObjectTable _objects; private readonly LiveEntityRuntime _liveEntities; private readonly Func _acceptParent; + private readonly Func + _withdrawProjection; private readonly EntityEffectPoseRegistry _poses; private ParentAttachmentState Relations => _liveEntities.ParentAttachments; @@ -40,6 +42,13 @@ public sealed class EquippedChildRenderController : IDisposable /// Raised when an attached projection leaves its cell presentation. public event Action? ProjectionRemoved; private readonly Dictionary _attachedByChild = new(); + private readonly Dictionary _pendingUnparentByChild = new(); + private readonly Dictionary _pendingOrdinaryRemovalByRoot = new(); + private readonly Dictionary _pendingDetachedRemovalByChild = new(); + private readonly Dictionary _pendingReparentRemovalByChild = new(); + private readonly Dictionary _pendingPoseLossRemovalByChild = new(); + private readonly Dictionary _pendingOrphanRemovalByChild = new(); + private readonly List _pendingProjectionChildrenScratch = new(); private readonly AttachmentUpdateOrder _updateOrder = new(); private readonly Func _parentOfAttached; private readonly Func _tickAttached; @@ -59,7 +68,9 @@ public sealed class EquippedChildRenderController : IDisposable ClientObjectTable objects, LiveEntityRuntime liveEntities, EntityEffectPoseRegistry poses, - Func acceptParent) + Func acceptParent, + Func + withdrawProjection) { _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); @@ -67,6 +78,8 @@ public sealed class EquippedChildRenderController : IDisposable _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _poses = poses ?? throw new ArgumentNullException(nameof(poses)); _acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent)); + _withdrawProjection = withdrawProjection + ?? throw new ArgumentNullException(nameof(withdrawProjection)); _parentOfAttached = static child => child.ParentGuid; _tickAttached = TickChild; @@ -144,14 +157,18 @@ public sealed class EquippedChildRenderController : IDisposable } } - public void OnGenerationReplaced(uint guid, ushort replacementGeneration) + /// + /// Removes projections owned by one retired incarnation. The runtime has + /// already removed that record from its active GUID table, so the root's + /// attached-map entry is committed by exact record identity without a + /// GUID lookup. Still-live descendants use the normal exact withdrawal + /// path and remain eligible to replay their retained parent relation. + /// + public void OnLogicalTeardown(LiveEntityRecord record) { - TearDownObjectProjections(guid); - } - - public void OnGenerationDeleted(uint guid, ushort deletedGeneration) - { - TearDownObjectProjections(guid); + ArgumentNullException.ThrowIfNull(record); + lock (_datLock) + TearDownRecordProjections(record); } private void OnObjectRemovalClassified(ClientObjectRemoval removal) @@ -160,13 +177,12 @@ public sealed class EquippedChildRenderController : IDisposable return; uint guid = removal.Object.ObjectId; - TearDownObjectProjections(guid); + TearDownCurrentObjectProjections(guid); // InventoryRemoveObject removes the item from the UI view, not the // live physics generation. Preserve fresher pending ParentEvents so a // same-generation world/parent update can still replay them. Logical // delete/replacement are driven directly by the accepted inbound // lifecycle and never depend on this table. - Relations.EndChildProjection(guid); } /// @@ -175,15 +191,32 @@ public sealed class EquippedChildRenderController : IDisposable /// ParentEvents; unlike logical deletion, do not disturb child-addressed /// pending state or children that may themselves reference it. /// - public void OnChildBecameUnparented(uint childGuid) + public ChildUnparentDisposition OnChildBecameUnparented( + uint childGuid, + Action? continuation = null) { - Remove(childGuid); - Relations.EndChildProjection(childGuid); + if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record)) + { + _pendingUnparentByChild.Remove(childGuid); + Relations.EndChildProjection(childGuid); + return ChildUnparentDisposition.NotAttached; + } + + var pending = new PendingUnparentTransition( + record, + record.PositionAuthorityVersion, + CaptureProjectionSubtreeParentFirst( + record, + restoreRootRelation: false, + restoreDescendantRelations: true), + continuation); + return AdvanceUnparentTransition(childGuid, pending); } /// Recompose every child after the parent's animation tick. public void Tick() { + RetryPendingProjectionTransitions(); IReadOnlyList failed = _updateOrder.ForEachParentFirst( _attachedByChild, _parentOfAttached, @@ -192,12 +225,48 @@ public sealed class EquippedChildRenderController : IDisposable WithdrawForPoseLoss(failed[i]); } + private void RetryPendingProjectionTransitions() + { + foreach ((uint rootGuid, PendingOrdinaryRemoval pending) in + _pendingOrdinaryRemovalByRoot.ToArray()) + { + AdvanceOrdinaryRemoval(rootGuid, pending); + } + + foreach ((uint childGuid, PendingProjectionSubtree pending) in + _pendingDetachedRemovalByChild.ToArray()) + { + AdvanceDetachedRemoval(childGuid, pending); + } + + RetryProjectionSubtrees(_pendingReparentRemovalByChild); + RetryProjectionSubtrees(_pendingPoseLossRemovalByChild); + RetryProjectionSubtrees(_pendingOrphanRemovalByChild); + + foreach ((uint childGuid, PendingUnparentTransition pending) in + _pendingUnparentByChild.ToArray()) + { + if (!_liveEntities.IsCurrentRecord(pending.Record) + || pending.Record.PositionAuthorityVersion + != pending.PositionAuthorityVersion) + { + _pendingUnparentByChild.Remove(childGuid); + continue; + } + AdvanceUnparentTransition(childGuid, pending); + } + + Relations.CopyPendingProjectionChildrenTo(_pendingProjectionChildrenScratch); + for (int i = 0; i < _pendingProjectionChildrenScratch.Count; i++) + ResolveAndTryRealize(_pendingProjectionChildrenScratch[i]); + } + private bool TickChild(uint childGuid) { if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)) return false; - if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent) + if (TryResolveExactAttachment(child, out WorldEntity parent) && _poses.TryGetRootPose(parent.Id, out Matrix4x4 parentWorld) && _poses.TryGetPartPoseSnapshot( parent.Id, @@ -223,7 +292,8 @@ public sealed class EquippedChildRenderController : IDisposable ApplyParentDrawVisibility(child.Entity, parent); child.Entity.ParentCellId = parent.ParentCellId; PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose); - if (parent.ParentCellId is { } parentCellId) + if (TryResolveExactAttachment(child, out parent) + && parent.ParentCellId is { } parentCellId) _liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId); ProjectionPoseReady?.Invoke(child.ChildGuid); return true; @@ -231,12 +301,16 @@ public sealed class EquippedChildRenderController : IDisposable return false; } - private bool TryRealize(uint childGuid) + private bool TryRealize( + ParentAttachmentRelation pending, + ParentProjectionCandidateKind candidateKind) { - if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending)) - return false; + uint childGuid = pending.ChildGuid; - if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity) + if (!_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord) + || parentRecord.WorldEntity is not { } parentEntity + || !parentRecord.IsSpatiallyProjected + || !_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord childRecord) || !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn) || !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn)) return false; @@ -295,7 +369,12 @@ public sealed class EquippedChildRenderController : IDisposable _liveEntities.SetEffectProfile(childGuid, effectProfile); } - Remove(childGuid); + if (!_liveEntities.IsCurrentRecord(parentRecord) + || !_liveEntities.IsCurrentRecord(childRecord) + || !Relations.IsPending(pending, candidateKind)) + { + return false; + } WorldEntity? entity = _liveEntities.MaterializeLiveEntity( childGuid, parentCellId, @@ -318,6 +397,24 @@ public sealed class EquippedChildRenderController : IDisposable LiveEntityProjectionKind.Attached); if (entity is null) return false; + if (!_liveEntities.IsCurrentRecord(parentRecord) + || !_liveEntities.IsCurrentRecord(childRecord) + || !ReferenceEquals(childRecord.WorldEntity, entity) + || !Relations.IsPending(pending, candidateKind)) + { + if (_liveEntities.IsCurrentRecord(childRecord) + && ReferenceEquals(childRecord.WorldEntity, entity) + && childRecord.IsSpatiallyProjected) + { + BeginProjectionSubtreeWithdrawal( + _pendingOrphanRemovalByChild, + childRecord, + restoreRootRelation: false, + restoreDescendantRelations: false); + } + return false; + } + childRecord.HasPartArray = true; ApplyParentWorldPose(entity, parentWorld); ApplyParentDrawVisibility(entity, parentEntity); entity.ParentCellId = parentCellId; @@ -329,6 +426,8 @@ public sealed class EquippedChildRenderController : IDisposable : Array.Empty()); entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability); _attachedByChild[childGuid] = new AttachedChild( + parentRecord, + childRecord, pending.ParentGuid, childGuid, parentLocation, @@ -341,8 +440,8 @@ public sealed class EquippedChildRenderController : IDisposable pose.AttachedParts, scale, entity); - if (_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord) - && (parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) + _pendingUnparentByChild.Remove(childGuid); + if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) { // A child attached while its parent is already Hidden inherits // the same direct child NoDraw mutation retail applies from @@ -353,7 +452,7 @@ public sealed class EquippedChildRenderController : IDisposable Console.WriteLine( $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"location={parentLocation} placement={placement}"); - Relations.MarkProjected(childGuid); + Relations.MarkProjected(pending, candidateKind); ProjectionPoseReady?.Invoke(childGuid); EntityReady?.Invoke(childGuid); return true; @@ -420,7 +519,7 @@ public sealed class EquippedChildRenderController : IDisposable { foreach (AttachedChild child in _attachedByChild.Values) { - if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent) + if (!TryResolveExactAttachment(child, out WorldEntity parent) || parent.Id != parentLocalId || !child.ParentSetup.HoldingLocations.TryGetValue( child.ParentLocation, @@ -441,7 +540,7 @@ public sealed class EquippedChildRenderController : IDisposable { if (child.Entity.Id != childLocalId) continue; - if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)) + if (TryResolveExactAttachment(child, out WorldEntity parent)) return parent.Id; return null; } @@ -457,11 +556,46 @@ public sealed class EquippedChildRenderController : IDisposable { foreach (AttachedChild child in _attachedByChild.Values) { - if (child.ParentGuid == parentGuid) + if (child.ParentGuid == parentGuid + && TryResolveExactAttachment(child, out _)) _liveEntities.SetAttachedChildNoDraw(child.ChildGuid, noDraw); } } + public void OnCreateParentAccepted(CreateParentUpdate update) + { + lock (_datLock) + { + Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation( + update.ParentGuid, + update.ChildGuid, + update.ParentLocation, + update.PlacementId, + ParentInstanceSequence: 0, + update.ChildPositionSequence)); + ResolveAndTryRealize(update.ChildGuid); + } + } + + private bool TryResolveExactAttachment( + AttachedChild child, + out WorldEntity parent) + { + if (_liveEntities.IsCurrentRecord(child.ParentRecord) + && _liveEntities.IsCurrentRecord(child.ChildRecord) + && child.ParentRecord.IsSpatiallyProjected + && child.ChildRecord.IsSpatiallyProjected + && child.ParentRecord.WorldEntity is { } exactParent + && child.ChildRecord.WorldEntity is { } exactChild + && ReferenceEquals(exactChild, child.Entity)) + { + parent = exactParent; + return true; + } + parent = null!; + return false; + } + private void ResolveRelations(uint childGuid) { Relations.Resolve( @@ -475,10 +609,118 @@ public sealed class EquippedChildRenderController : IDisposable private bool ResolveAndTryRealize(uint childGuid) { - ResolveRelations(childGuid); - return TryRealize(childGuid); + bool projected = false; + while (true) + { + ResolveRelations(childGuid); + if (!Relations.TryGetStagedProjection( + childGuid, + out ParentAttachmentRelation staged)) + { + break; + } + + ParentProjectionValidationDisposition validation = + ValidateParentProjection(staged); + if (validation is ParentProjectionValidationDisposition.Waiting) + break; + if (validation is ParentProjectionValidationDisposition.Rejected) + { + Relations.RejectProjection(staged); + continue; + } + + ProjectionPreparationResult result = PrepareAndTryRealize( + staged, + ParentProjectionCandidateKind.Staged); + projected |= result.Projected; + if (!result.CanAdvanceWireQueue) + return projected; + } + + if (Relations.TryGetRecoveryProjection( + childGuid, + out ParentAttachmentRelation recovery) + && ValidateParentProjection(recovery) + is ParentProjectionValidationDisposition.Ready) + { + projected |= PrepareAndTryRealize( + recovery, + ParentProjectionCandidateKind.Recovery).Projected; + } + return projected; } + private ProjectionPreparationResult PrepareAndTryRealize( + ParentAttachmentRelation relation, + ParentProjectionCandidateKind candidateKind) + { + if (!_liveEntities.TryGetRecord( + relation.ChildGuid, + out LiveEntityRecord childRecord)) + { + return default; + } + + ulong positionAuthorityVersion = childRecord.PositionAuthorityVersion; + if (candidateKind is ParentProjectionCandidateKind.Staged) + { + if (!_liveEntities.CommitStagedParent(relation, out _) + || !Relations.CommitProjection(relation)) + { + return default; + } + candidateKind = ParentProjectionCandidateKind.Recovery; + } + else if (!Relations.IsCommitted(relation)) + { + return default; + } + + if (!Relations.IsPending(relation, candidateKind) + || !_liveEntities.CommitAcceptedParentCellless( + childRecord, + positionAuthorityVersion) + || !WithdrawPriorProjection(childRecord)) + { + return default; + } + return new( + CanAdvanceWireQueue: true, + Projected: TryRealize(relation, candidateKind)); + } + + internal ParentProjectionValidationDisposition ValidateParentProjection( + ParentAttachmentRelation relation) + { + if (relation.ParentGuid == relation.ChildGuid) + return ParentProjectionValidationDisposition.Rejected; + if (!_liveEntities.TryGetRecord(relation.ParentGuid, out LiveEntityRecord parent) + || !_liveEntities.TryGetRecord(relation.ChildGuid, out _) + || parent.WorldEntity is null + || !parent.HasPartArray) + { + return ParentProjectionValidationDisposition.Waiting; + } + if (!_liveEntities.TryGetSnapshot( + relation.ParentGuid, + out WorldSession.EntitySpawn parentSpawn) + || parentSpawn.SetupTableId is not { } parentSetupId) + { + return ParentProjectionValidationDisposition.Rejected; + } + Setup? parentSetup = _dats.Get(parentSetupId); + return parentSetup is not null + && parentSetup.HoldingLocations.ContainsKey( + (ParentLocation)relation.ParentLocation) + ? ParentProjectionValidationDisposition.Ready + : ParentProjectionValidationDisposition.Rejected; + } + + private readonly record struct ProjectionPreparationResult( + bool CanAdvanceWireQueue, + bool Projected); + private void RetryWaitingDescendants(uint parentGuid) { _updateOrder.RealizeDescendants( @@ -574,13 +816,52 @@ public sealed class EquippedChildRenderController : IDisposable private void OnObjectMoved(ClientObjectMove move) { if (move.Current.EquipLocation == EquipMask.None) - Remove(move.ItemId); + BeginDetachedRemoval(move.ItemId); + } + + private void BeginDetachedRemoval(uint childGuid) + { + if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record)) + return; + BeginProjectionSubtreeWithdrawal( + _pendingDetachedRemovalByChild, + record, + restoreRootRelation: false, + restoreDescendantRelations: true); + } + + private void AdvanceDetachedRemoval( + uint childGuid, + PendingProjectionSubtree pending) + { + AdvanceProjectionSubtree( + _pendingDetachedRemovalByChild, + childGuid, + pending); } private void OnMoveRolledBack(ClientObject item) { if (item.CurrentlyEquippedLocation == EquipMask.None) return; + if (_attachedByChild.TryGetValue(item.ObjectId, out AttachedChild? attached) + && _liveEntities.IsCurrentRecord(attached.ChildRecord) + && attached.ChildRecord.IsSpatiallyProjected) + { + // A component-stage failure left the exact equipped projection + // untouched. The authoritative rollback therefore has nothing to + // reconstruct and merely cancels its retained leave-world retry. + _pendingDetachedRemovalByChild.Remove(item.ObjectId); + return; + } + if (_pendingDetachedRemovalByChild.TryGetValue( + item.ObjectId, + out PendingProjectionSubtree pending)) + { + AdvanceDetachedRemoval(item.ObjectId, pending); + if (_pendingDetachedRemovalByChild.ContainsKey(item.ObjectId)) + return; + } // A rejected unwield restores the equipped location without a fresh // wire ParentEvent; reinstall the last accepted relationship only for @@ -595,52 +876,516 @@ public sealed class EquippedChildRenderController : IDisposable } } - private void Remove(uint childGuid) + private bool Remove(uint childGuid) { - if (!_attachedByChild.Remove(childGuid, out AttachedChild? child)) - return; - _liveEntities.WithdrawLiveEntityProjection(childGuid); - _poses.Remove(child.Entity.Id); + if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)) + return false; + if (!_liveEntities.IsCurrentRecord(child.ChildRecord)) + return CommitProjectionRemoval(child); + return Remove( + child, + child.ChildRecord.PositionAuthorityVersion, + child.ChildRecord.ProjectionMutationVersion); + } + + private bool Remove( + AttachedChild child, + ulong positionAuthorityVersion, + ulong projectionMutationVersion) + { + if (!_liveEntities.IsCurrentRecord(child.ChildRecord)) + return CommitProjectionRemoval(child); + ExactProjectionWithdrawalOutcome outcome = WithdrawAttachedProjection( + child.ChildRecord, + positionAuthorityVersion, + projectionMutationVersion, + _withdrawProjection, + () => CommitProjectionRemoval(child)); + if (outcome.Failure is not null) + throw outcome.Failure; + return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed; + } + + private bool WithdrawPriorProjection(LiveEntityRecord childRecord) + { + if (_pendingReparentRemovalByChild.TryGetValue( + childRecord.ServerGuid, + out PendingProjectionSubtree pending)) + { + return AdvanceProjectionSubtree( + _pendingReparentRemovalByChild, + childRecord.ServerGuid, + pending); + } + return BeginProjectionSubtreeWithdrawal( + _pendingReparentRemovalByChild, + childRecord, + restoreRootRelation: false, + restoreDescendantRelations: true); + } + + private ChildUnparentDisposition AdvanceUnparentTransition( + uint childGuid, + PendingUnparentTransition pending) + { + ExactProjectionWithdrawalOutcome outcome = AdvanceProjectionSubtree( + pending.Subtree, + out PendingProjectionSubtree next); + + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) + { + _pendingUnparentByChild[childGuid] = pending with { Subtree = next }; + if (outcome.Failure is not null) + throw outcome.Failure; + return ChildUnparentDisposition.Pending; + } + + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Superseded) + { + _pendingUnparentByChild.Remove(childGuid); + if (outcome.Failure is not null) + throw outcome.Failure; + return ChildUnparentDisposition.Superseded; + } + + PendingUnparentTransition awaitingContinuation = pending with { Subtree = next }; + _pendingUnparentByChild[childGuid] = awaitingContinuation; + if (outcome.Failure is not null) + throw outcome.Failure; + + Relations.EndChildProjection(childGuid); + try + { + awaitingContinuation.Continuation?.Invoke(); + _pendingUnparentByChild.Remove(childGuid); + return ChildUnparentDisposition.Completed; + } + catch + { + bool continuationCommitted = + _liveEntities.IsCurrentRecord(awaitingContinuation.Record) + && awaitingContinuation.Record.PositionAuthorityVersion + == awaitingContinuation.PositionAuthorityVersion + && awaitingContinuation.Record.IsSpatiallyProjected + && awaitingContinuation.Record.ProjectionKind + is LiveEntityProjectionKind.World; + if (continuationCommitted) + _pendingUnparentByChild.Remove(childGuid); + else + _pendingUnparentByChild[childGuid] = awaitingContinuation; + throw; + } + } + + internal static ExactProjectionWithdrawalOutcome WithdrawAttachedProjection( + LiveEntityRecord childRecord, + ulong positionAuthorityVersion, + ulong projectionMutationVersion, + Func + withdrawProjection, + Func commitRemoval) + { + ArgumentNullException.ThrowIfNull(childRecord); + ArgumentNullException.ThrowIfNull(withdrawProjection); + ArgumentNullException.ThrowIfNull(commitRemoval); + ExactProjectionWithdrawalOutcome outcome = withdrawProjection( + childRecord, + positionAuthorityVersion, + projectionMutationVersion); + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) + return outcome; + try + { + commitRemoval(); + return outcome; + } + catch (Exception error) + { + return new ExactProjectionWithdrawalOutcome( + outcome.Disposition, + outcome.Failure is null + ? error + : new AggregateException(outcome.Failure, error)); + } + } + + private bool CommitProjectionRemoval(AttachedChild child) + { + if (!_attachedByChild.TryGetValue(child.ChildGuid, out AttachedChild? current) + || !ReferenceEquals(current, child)) + { + return false; + } + + _attachedByChild.Remove(child.ChildGuid); ProjectionRemoved?.Invoke(child.Entity.Id); + return true; } private void WithdrawForPoseLoss(uint childGuid) { - if (!_attachedByChild.ContainsKey(childGuid)) + if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record)) return; - Remove(childGuid); - // MarkProjected removed the active candidate but retained the last - // accepted relationship. Requeue that exact accepted relation so a - // later parent pose/appearance registration can realize it again. - Relations.RestoreLastAccepted(childGuid); + BeginProjectionSubtreeWithdrawal( + _pendingPoseLossRemovalByChild, + record, + restoreRootRelation: true, + restoreDescendantRelations: true); } - private void TearDownObjectProjections(uint guid) + private void TearDownCurrentObjectProjections(uint guid) { - IReadOnlyList subtree = _updateOrder.CollectSubtreePostOrder( - _attachedByChild, - guid, - _parentOfAttached); - for (int i = 0; i < subtree.Count; i++) + if (_liveEntities.TryGetRecord(guid, out LiveEntityRecord record)) { - uint childGuid = subtree[i]; - Remove(childGuid); - if (childGuid != guid) + List captures = CaptureRecordSubtreeParentFirst(record); + AdvanceOrdinaryRemoval( + guid, + new PendingOrdinaryRemoval(record, captures, NextIndex: 0)); + return; + } + + if (_attachedByChild.TryGetValue(guid, out AttachedChild? stale)) + CommitProjectionRemoval(stale); + Relations.EndChildProjection(guid); + } + + private void AdvanceOrdinaryRemoval( + uint rootGuid, + PendingOrdinaryRemoval pending) + { + if (!_liveEntities.IsCurrentRecord(pending.RootRecord)) + { + _pendingOrdinaryRemovalByRoot.Remove(rootGuid); + return; + } + + for (int i = pending.NextIndex; i < pending.Captures.Count; i++) + { + AttachedRemovalCapture captured = pending.Captures[i]; + ExactProjectionWithdrawalOutcome outcome = WithdrawCaptured(captured); + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) { - // Retail unparents direct children. Descendant relationships - // can become renderable again if their immediate parent later - // acquires a top-level Position, so retain their accepted - // relationship as a pending projection candidate. - Relations.RestoreLastAccepted(childGuid); + _pendingOrdinaryRemovalByRoot[rootGuid] = pending with { NextIndex = i }; + if (outcome.Failure is not null) + throw outcome.Failure; + return; + } + + if (!ReferenceEquals(captured.Attached.ChildRecord, pending.RootRecord) + && outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed) + { + Relations.RestoreLastAccepted(captured.Attached.ChildGuid); + } + + if (outcome.Failure is not null) + { + _pendingOrdinaryRemovalByRoot[rootGuid] = pending with + { + NextIndex = i + 1, + }; + throw outcome.Failure; } } + + _pendingOrdinaryRemovalByRoot.Remove(rootGuid); + Relations.EndChildProjection(rootGuid); + } + + private void TearDownRecordProjections(LiveEntityRecord record) + { + if (_pendingUnparentByChild.TryGetValue(record.ServerGuid, out var pending) + && ReferenceEquals(pending.Record, record)) + { + _pendingUnparentByChild.Remove(record.ServerGuid); + } + if (_pendingOrdinaryRemovalByRoot.TryGetValue( + record.ServerGuid, + out PendingOrdinaryRemoval ordinary) + && ReferenceEquals(ordinary.RootRecord, record)) + { + _pendingOrdinaryRemovalByRoot.Remove(record.ServerGuid); + } + RemovePendingProjectionSubtree(_pendingDetachedRemovalByChild, record); + RemovePendingProjectionSubtree(_pendingReparentRemovalByChild, record); + RemovePendingProjectionSubtree(_pendingPoseLossRemovalByChild, record); + RemovePendingProjectionSubtree(_pendingOrphanRemovalByChild, record); + List subtree = CaptureRecordSubtreeParentFirst(record); + for (int i = 0; i < subtree.Count; i++) + { + AttachedRemovalCapture captured = subtree[i]; + AttachedChild child = captured.Attached; + if (ReferenceEquals(child.ChildRecord, record)) + { + CommitProjectionRemoval(child); + continue; + } + ExactProjectionWithdrawalOutcome outcome = WithdrawCaptured(captured); + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) + { + if (outcome.Failure is not null) + throw outcome.Failure; + throw new InvalidOperationException( + $"Attached projection 0x{child.ChildGuid:X8} remains pending exact teardown of 0x{record.ServerGuid:X8}."); + } + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed) + Relations.RestoreLastAccepted(child.ChildGuid); + if (outcome.Failure is not null) + throw outcome.Failure; + } + } + + private List CaptureRecordSubtreeParentFirst( + LiveEntityRecord record) + { + var result = new List(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + if (_attachedByChild.TryGetValue(record.ServerGuid, out AttachedChild? root) + && ReferenceEquals(root.ChildRecord, record)) + { + result.Add(Capture(root)); + } + CollectDescendantsParentFirst(record, result, visited); + return result; + } + + private void CollectDescendantsParentFirst( + LiveEntityRecord parent, + List destination, + HashSet visited) + { + if (!visited.Add(parent)) + return; + AttachedChild[] children = _attachedByChild.Values + .Where(child => ReferenceEquals(child.ParentRecord, parent)) + .ToArray(); + for (int i = 0; i < children.Length; i++) + { + destination.Add(Capture(children[i])); + CollectDescendantsParentFirst(children[i].ChildRecord, destination, visited); + } + } + + private static AttachedRemovalCapture Capture(AttachedChild child) => new( + child, + child.ChildRecord.PositionAuthorityVersion, + child.ChildRecord.ProjectionMutationVersion); + + private ExactProjectionWithdrawalOutcome WithdrawCaptured( + AttachedRemovalCapture captured) + { + if (!_attachedByChild.TryGetValue( + captured.Attached.ChildGuid, + out AttachedChild? current) + || !ReferenceEquals(current, captured.Attached)) + { + return new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + return WithdrawAttachedProjection( + captured.Attached.ChildRecord, + captured.PositionAuthorityVersion, + captured.ProjectionMutationVersion, + _withdrawProjection, + () => CommitProjectionRemoval(captured.Attached)); + } + + private PendingProjectionSubtree CaptureProjectionSubtreeParentFirst( + LiveEntityRecord root, + bool restoreRootRelation, + bool restoreDescendantRelations) + { + var captures = new List(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + CaptureProjectionNode(root, isRoot: true, captures, visited); + return new PendingProjectionSubtree( + root, + root.PositionAuthorityVersion, + captures, + NextIndex: 0, + restoreRootRelation, + restoreDescendantRelations); + } + + private void CaptureProjectionNode( + LiveEntityRecord record, + bool isRoot, + List destination, + HashSet visited) + { + if (!visited.Add(record)) + return; + _attachedByChild.TryGetValue(record.ServerGuid, out AttachedChild? attached); + if (attached is not null && !ReferenceEquals(attached.ChildRecord, record)) + attached = null; + if (record.IsSpatiallyProjected || attached is not null) + { + destination.Add(new ProjectionRemovalCapture( + record, + record.PositionAuthorityVersion, + record.ProjectionMutationVersion, + attached, + isRoot)); + } + + AttachedChild[] children = _attachedByChild.Values + .Where(child => ReferenceEquals(child.ParentRecord, record)) + .ToArray(); + for (int i = 0; i < children.Length; i++) + { + CaptureProjectionNode( + children[i].ChildRecord, + isRoot: false, + destination, + visited); + } + } + + private bool BeginProjectionSubtreeWithdrawal( + Dictionary pendingByRoot, + LiveEntityRecord root, + bool restoreRootRelation, + bool restoreDescendantRelations) + { + PendingProjectionSubtree pending = CaptureProjectionSubtreeParentFirst( + root, + restoreRootRelation, + restoreDescendantRelations); + return AdvanceProjectionSubtree(pendingByRoot, root.ServerGuid, pending); + } + + private bool AdvanceProjectionSubtree( + Dictionary pendingByRoot, + uint rootGuid, + PendingProjectionSubtree pending) + { + ExactProjectionWithdrawalOutcome outcome = AdvanceProjectionSubtree( + pending, + out PendingProjectionSubtree next); + bool retry = outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending + || (outcome.Failure is not null && next.NextIndex < next.Captures.Count); + if (retry) + pendingByRoot[rootGuid] = next; + else + pendingByRoot.Remove(rootGuid); + if (outcome.Failure is not null) + throw outcome.Failure; + return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed + && next.NextIndex >= next.Captures.Count; + } + + private ExactProjectionWithdrawalOutcome AdvanceProjectionSubtree( + PendingProjectionSubtree pending, + out PendingProjectionSubtree next) + { + next = pending; + if (!_liveEntities.IsCurrentRecord(pending.RootRecord) + || pending.RootRecord.PositionAuthorityVersion + != pending.RootPositionAuthorityVersion) + { + return new( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + + for (int i = pending.NextIndex; i < pending.Captures.Count; i++) + { + ProjectionRemovalCapture captured = pending.Captures[i]; + ExactProjectionWithdrawalOutcome outcome = WithdrawProjectionCapture(captured); + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) + { + next = pending with { NextIndex = i }; + return outcome; + } + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Superseded + && captured.IsRoot) + { + next = pending with { NextIndex = i + 1 }; + return outcome; + } + if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed + && captured.Attached is not null + && (captured.IsRoot + ? pending.RestoreRootRelation + : pending.RestoreDescendantRelations)) + { + Relations.RestoreLastAccepted(captured.Record.ServerGuid); + } + next = pending with { NextIndex = i + 1 }; + if (outcome.Failure is not null) + return outcome; + } + + return new( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null); + } + + private ExactProjectionWithdrawalOutcome WithdrawProjectionCapture( + ProjectionRemovalCapture captured) + { + if (captured.Attached is { } attached) + { + return WithdrawCaptured(new AttachedRemovalCapture( + attached, + captured.PositionAuthorityVersion, + captured.ProjectionMutationVersion)); + } + if (!_liveEntities.IsCurrentRecord(captured.Record) + || captured.Record.PositionAuthorityVersion + != captured.PositionAuthorityVersion + || captured.Record.ProjectionMutationVersion + != captured.ProjectionMutationVersion) + { + return new( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + if (!captured.Record.IsSpatiallyProjected) + { + return new( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null); + } + return _withdrawProjection( + captured.Record, + captured.PositionAuthorityVersion, + captured.ProjectionMutationVersion); + } + + private void RetryProjectionSubtrees( + Dictionary pendingByRoot) + { + foreach ((uint rootGuid, PendingProjectionSubtree pending) in + pendingByRoot.ToArray()) + { + AdvanceProjectionSubtree(pendingByRoot, rootGuid, pending); + } + } + + private static void RemovePendingProjectionSubtree( + Dictionary pendingByRoot, + LiveEntityRecord record) + { + uint[] keys = pendingByRoot + .Where(pair => ReferenceEquals(pair.Value.RootRecord, record)) + .Select(pair => pair.Key) + .ToArray(); + for (int i = 0; i < keys.Length; i++) + pendingByRoot.Remove(keys[i]); } public void Clear() { - uint[] attached = _attachedByChild.Keys.ToArray(); + AttachedChild[] attached = _attachedByChild.Values.ToArray(); for (int i = 0; i < attached.Length; i++) - Remove(attached[i]); + CommitProjectionRemoval(attached[i]); + _pendingUnparentByChild.Clear(); + _pendingOrdinaryRemovalByRoot.Clear(); + _pendingDetachedRemovalByChild.Clear(); + _pendingReparentRemovalByChild.Clear(); + _pendingPoseLossRemovalByChild.Clear(); + _pendingOrphanRemovalByChild.Clear(); Relations.Clear(); } @@ -653,6 +1398,8 @@ public sealed class EquippedChildRenderController : IDisposable } private sealed record AttachedChild( + LiveEntityRecord ParentRecord, + LiveEntityRecord ChildRecord, uint ParentGuid, uint ChildGuid, ParentLocation ParentLocation, @@ -665,4 +1412,50 @@ public sealed class EquippedChildRenderController : IDisposable MeshRef[] AttachedPartBuffer, float Scale, WorldEntity Entity); + + private readonly record struct PendingUnparentTransition( + LiveEntityRecord Record, + ulong PositionAuthorityVersion, + PendingProjectionSubtree Subtree, + Action? Continuation); + + private readonly record struct PendingProjectionSubtree( + LiveEntityRecord RootRecord, + ulong RootPositionAuthorityVersion, + IReadOnlyList Captures, + int NextIndex, + bool RestoreRootRelation, + bool RestoreDescendantRelations); + + private readonly record struct ProjectionRemovalCapture( + LiveEntityRecord Record, + ulong PositionAuthorityVersion, + ulong ProjectionMutationVersion, + AttachedChild? Attached, + bool IsRoot); + + private readonly record struct AttachedRemovalCapture( + AttachedChild Attached, + ulong PositionAuthorityVersion, + ulong ProjectionMutationVersion); + + private readonly record struct PendingOrdinaryRemoval( + LiveEntityRecord RootRecord, + IReadOnlyList Captures, + int NextIndex); +} + +public enum ChildUnparentDisposition +{ + NotAttached, + Completed, + Pending, + Superseded, +} + +internal enum ParentProjectionValidationDisposition +{ + Ready, + Waiting, + Rejected, } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index dada8342..37de367f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -19,6 +19,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene); private readonly AcDream.App.RuntimeOptions _options; + private readonly AnimationPresentationDiagnostics _animationDiagnostics; private readonly string _datDir; private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; @@ -192,6 +193,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Step 7 projectile presentation. The controller owns no identity map; // each runtime component is stored on the canonical LiveEntityRecord. private AcDream.App.Physics.ProjectileController? _projectileController; + private AcDream.App.World.LiveEntityProjectionWithdrawalController? + _liveEntityProjectionWithdrawal; // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); @@ -300,11 +303,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new(); - private sealed record AppearanceUpdateState( - AcDream.Core.World.WorldEntity Entity, - LiveEntityAnimationState? Animation); - private AcDream.Core.Physics.IAnimationLoader? _animLoader; + private AcDream.App.Physics.LiveEntityCollisionBuilder? _liveEntityCollisionBuilder; // Phase E.1: central fan-out for animation hooks. Audio (E.2), // particles (E.3), combat (E.4), and renderer state mutators all @@ -591,9 +591,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character private MovementTruthOutbound? _lastMovementTruthOutbound; - private (System.Numerics.Vector3 Position, - System.Numerics.Quaternion Orientation, - uint CellId)? _lastLocalPlayerShadow; + private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new(); private readonly record struct MovementTruthOutbound( string Kind, @@ -815,6 +813,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _worldGameState = worldGameState; _worldEvents = worldEvents; _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); + _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView(() => _liveEntities); @@ -847,7 +846,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext () => _staticAnimationScheduler, _effectPoses, this, - AnimationPresentationDiagnostics.FromEnvironment(), + _animationDiagnostics, options.HidePartIndex); _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( resolveEntity: () => @@ -1182,6 +1181,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext SpellBook.InstallMetadata(_magicCatalog.SpellTable); Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat"); _animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats); + _liveEntityCollisionBuilder = new AcDream.App.Physics.LiveEntityCollisionBuilder( + _physicsDataCache, + new AcDream.App.Physics.LiveEntityDefaultPoseResolver( + id => _dats.Get(id), + _animLoader, + _animationDiagnostics.DumpMotionEnabled)); _emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats); // Phase E.3 particles: always-on, no driver dependency. Registered @@ -2281,6 +2286,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), }; + _liveEntityProjectionWithdrawal = + new AcDream.App.World.LiveEntityProjectionWithdrawalController( + _liveEntities, + _projectileController, + _worldGameState, + _worldEvents, + _physicsEngine.ShadowObjects, + _effectPoses, + _localPlayerShadow); _liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController( _liveEntities, _effectPoses, @@ -2293,7 +2307,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Objects, _liveEntities, _effectPoses, - TryAcceptParentForRender); + TryAcceptParentForRender, + (childRecord, positionVersion, projectionVersion) => + _liveEntityProjectionWithdrawal!.WithdrawExact( + childRecord, + positionVersion, + projectionVersion, + _playerServerGuid)); var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver( id => _dats!.Get(id)); @@ -2672,7 +2692,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _autoRunActive = false; _chaseModeEverEntered = false; _lastMovementTruthOutbound = null; - _lastLocalPlayerShadow = null; + _localPlayerShadow.Clear(); _spawnClaimRangeMemo = null; } } @@ -2988,13 +3008,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps); - if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration) - { - _equippedChildRenderer?.OnGenerationReplaced( - spawn.Guid, - result.Snapshot.InstanceSequence); - } - AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn( Objects, spawn, @@ -3156,7 +3169,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private void OnLiveEntitySpawnedLocked( AcDream.Core.Net.WorldSession.EntitySpawn spawn, LiveProjectionPurpose purpose, - AppearanceUpdateState? appearanceUpdate = null) + LiveEntityAppearanceUpdateState? appearanceUpdate = null) { _liveSpawnReceived++; @@ -3461,6 +3474,28 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // changes resolve (which match against the resolved mesh's // surfaces) and BEFORE the GfxObjMesh.Build / texture upload // path consumes the part list. + // Collision reads slot 0 of the installed CPhysicsPart degrade array. + // Keep this independent from the optional visual close-LOD policy: + // draw settings and Setup type must never change physical geometry. + uint[] collisionPartGfxObjIds = + AcDream.App.Physics.LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( + parts.Select(static part => part.GfxObjId).ToArray(), + baseId => + { + if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj( + _dats, + baseId, + out uint slotZeroId, + out DatReaderWriter.DBObjs.GfxObj? slotZeroGfx)) + { + return baseId; + } + + if (slotZeroGfx is not null) + _physicsDataCache.CacheGfxObj(slotZeroId, slotZeroGfx); + return slotZeroId; + }); + if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)) { for (int partIdx = 0; partIdx < parts.Count; partIdx++) @@ -3708,6 +3743,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (appearanceUpdate is { } visualUpdate) { AcDream.Core.World.WorldEntity existing = visualUpdate.Entity; + LiveEntityAppearanceCollisionUpdate? appearanceCollision = + LiveEntityAppearanceBinding.PrepareCollision( + _liveEntities!, + _liveEntityCollisionBuilder!, + existing, + setup, + collisionPartGfxObjIds, + spawn, + origin); _wbEntitySpawnAdapter!.OnAppearanceChanged( existing, meshRefs, @@ -3723,11 +3767,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext }, () => { + if (appearanceCollision is not null) + LiveEntityAppearanceBinding.CommitCollision( + _liveEntities, + _physicsEngine.ShadowObjects, + appearanceCollision); existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax)) existing.SetLocalBounds(appearanceMin, appearanceMax); if (visualUpdate.Animation is { } animation) - RebindAnimatedEntityForAppearance( + LiveEntityAppearanceBinding.RebindAnimation( animation, existing, setup, @@ -3844,8 +3893,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // are deliberately skipped — retail FUN's `FindObjCollisions` // falls through to OK_TS for any object with no collision // geometry (acclient_2013_pseudo_c.txt:276917,276987). - RegisterLiveEntityCollision(entity, setup, spawn, origin); - if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord)) + if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord) + && _liveEntityCollisionBuilder!.Build( + entity, + setup, + collisionPartGfxObjIds, + spawn, + liveRecord, + origin) is { } collision) + { + AcDream.App.Physics.LiveEntityCollisionBuilder.Register( + _physicsEngine.ShadowObjects, + collision); + } + if (_liveEntities.TryGetRecord(spawn.Guid, out liveRecord)) _projectileController?.TryBind( liveRecord, setup, @@ -4124,9 +4185,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext isLocalPlayer: delete.Guid == _playerServerGuid, beforeTeardown: () => { - _equippedChildRenderer?.OnGenerationDeleted( - delete.Guid, - delete.InstanceSequence); AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete); }); @@ -4174,7 +4232,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext lock (_datLock) { - AppearanceUpdateState? appearanceState = CaptureLiveAppearanceState(update.Guid); + LiveEntityAppearanceUpdateState? appearanceState = + LiveEntityAppearanceBinding.Capture(_liveEntities, update.Guid); OnLiveEntitySpawnedLocked( newSpawn, LiveProjectionPurpose.AppearanceMutation, @@ -4188,36 +4247,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _paperdollDollDirty = true; } - /// - /// Capture the runtime identity that retail preserves across - /// DoObjDescChangesFromDefault. Replacement visual data is hydrated - /// first and then assigned to this same entity instance. - /// - private AppearanceUpdateState? CaptureLiveAppearanceState(uint serverGuid) - { - if (_liveEntities?.TryGetWorldEntity(serverGuid, out var entity) != true) - return null; - - _animatedEntities.TryGetValue(entity.Id, out var animation); - return new AppearanceUpdateState(entity, animation); - } - - internal static void RebindAnimatedEntityForAppearance( - LiveEntityAnimationState animation, - AcDream.Core.World.WorldEntity entity, - DatReaderWriter.DBObjs.Setup setup, - float scale, - IReadOnlyList partTemplate, - IReadOnlyList partAvailability) - { - animation.Entity = entity; - animation.Setup = setup; - animation.Scale = scale; - animation.PartTemplate = partTemplate; - animation.PartAvailability = partAvailability; - animation.InvalidatePresentationPoses(); - } - /// /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / @@ -4327,175 +4356,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext doll.MeshRefs = reposed; } - /// - /// Commit B 2026-04-29 — register a live (server-spawned) entity into - /// the as a single collision body. - /// One entry per entity (in contrast to static scenery's per-CylSphere - /// registration) so the leave-world path's single - /// Deregister(entity.Id) cleans it up without leaks. - /// - /// - /// Geometry-priority order matches retail - /// (acclient_2013_pseudo_c.txt:276858-276987): CylSpheres > - /// Sphere fallback > Setup.Radius. Phantom Setups (no shape) are - /// rejected — retail's FindObjCollisions falls through to - /// OK_TS in that case. - /// - /// - /// - /// Carries derived from the PWD - /// bitfield (acclient.h:6431-6463) plus IsCreature - /// derived from the inbound ItemType. Commit C consumes these in - /// the PvP exemption block. - /// - /// - private void RegisterLiveEntityCollision( - AcDream.Core.World.WorldEntity entity, - DatReaderWriter.DBObjs.Setup setup, - AcDream.Core.Net.WorldSession.EntitySpawn spawn, - System.Numerics.Vector3 origin) - { - if (spawn.Position is null) return; - - bool hasCyl = setup.CylSpheres.Count > 0; - bool hasSphere = setup.Spheres.Count > 0; - bool hasRadius = setup.Radius > 0.0001f; - - // NOTE: We intentionally do NOT gate here on `!hasCyl && !hasSphere && !hasRadius`. - // That premature check was wrong: ShadowShapeBuilder.FromSetup also emits a BSP shape - // for any Part whose GfxObj has a non-null PhysicsBSP (e.g. a candle holder or floor - // candelabra with only a BSP collision mesh, no CylSphere/Sphere/Radius). Gating here - // would silently drop those BSP-only entities before the builder runs, making them - // invisible walls or fully passable. The correct final gate is at shapes.Count==0 - // below (after the builder + Radius fallback have run), which correctly handles ALL - // cases: BSP-only -> builder emits BSP shape -> registered; truly shapeless -> builder - // empty, no Radius fallback -> shapes.Count==0 -> return. Retail eligibility is - // "has physics_bsp OR cylsphere OR sphere" per CPhysicsObj::FindObjCollisions - // (acclient_2013_pseudo_c.txt:276917 context: the gate fires on the MOVER, not the - // target; no equivalent target-side gate skips BSP-only objects). - - float entScale = spawn.ObjScale ?? 1.0f; - - // A6.P4 door fix (2026-05-24): build the multi-part shape list. - // ShadowShapeBuilder emits one entry per CylSphere, one per Sphere - // (only when no CylSpheres), and one per Part with a non-null - // PhysicsBSP. Retail-faithful per CTransition::find_obj_collisions - // → CPartArray::FindObjCollisions - // (acclient_2013_pseudo_c.txt:286236). Pre-fix doors registered - // ONE small Cylinder via setup.Radius / Sphere — too narrow to - // span the doorway gap, so the player could walk through. With - // this change the door also registers the part-0 BSP slab - // (1.9 × 0.26 × 2.5 m) that retail uses for the real block. - // #175 (2026-07-05): BSP part shapes must pose at the motion table's - // DEFAULT-STATE frame (the closed pose — what the sequencer renders - // for an idle entity and what retail's live CPhysicsPart pose is), - // not the Setup's placement frame. The Facility Hub double door - // (Setup 0x02000C9D) places its panels AJAR in the placement frame - // (yaw −150°/−30°, −0.44 m behind the doorway) while rendering - // closed — the user embedded into the visual door on one side and - // hit a phantom slab on the other. Null (no motion table / no - // cycle / part-count mismatch) falls back to placement frames. - var closedPose = MotionTableDefaultPose( - spawn.MotionTableId ?? 0u, setup.Parts.Count); - - var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup( - setup, entScale, - id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null, - partPoseOverride: closedPose); - - // Substitute the real bounding-sphere radius for BSP shapes — - // the pure builder's 2.0 placeholder works for typical doors - // (BS radius ≈ 1.975 m) but is loose for larger entities and - // tight for smaller ones. Mirrors the landblock-static path's - // pattern of pulling the real radius from PhysicsDataCache. - var shapes = new List(raw.Count); - foreach (var s in raw) - { - if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.BSP) - { - var phys = _physicsDataCache.GetGfxObj(s.GfxObjId); - float bspR = (phys?.BoundingSphere?.Radius ?? 2f) * entScale; - shapes.Add(s with { Radius = bspR }); - } - else - { - shapes.Add(s); - } - } - - // setup.Radius fallback: the builder doesn't emit a Radius-only - // shape (it only walks CylSpheres / Spheres / Parts). For entities - // with no CylSpheres / Spheres / BSP-bearing Parts but a non-zero - // Radius (rare — simple decorative props), preserve the prior - // behavior of registering a setup.Radius cylinder so we don't - // silently regress those entities' collision. - if (shapes.Count == 0 && hasRadius) - { - shapes.Add(new AcDream.Core.Physics.ShadowShape( - GfxObjId: 0u, - LocalPosition: System.Numerics.Vector3.Zero, - LocalRotation: System.Numerics.Quaternion.Identity, - Scale: entScale, - CollisionType: AcDream.Core.Physics.ShadowCollisionType.Cylinder, - Radius: setup.Radius * entScale, - CylHeight: (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale)); - } - - if (shapes.Count == 0) - return; - - // Decode PvP / Player / Impenetrable from PWD._bitfield. - // IsCreature comes from the spawn's ItemType (server-known type). - // Every server-spawned live entity is backed by a CWeenieObject. - // Static landblock geometry is registered through the separate path - // with HasWeenie clear. Retail OBJECTINFO::missile_ignore 0x0050CEB0 - // needs this distinction for ethereal non-creatures such as doors. - var flags = AcDream.Core.Physics.EntityCollisionFlags.HasWeenie; - if (spawn.ObjectDescriptionFlags is { } odf) - flags |= AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf); - if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature) - flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature; - - uint state = _liveEntities?.TryGetRecord(spawn.Guid, out LiveEntityRecord liveRecord) == true - ? (uint)liveRecord.FinalPhysicsState - : spawn.PhysicsState ?? 0u; - - _physicsEngine.ShadowObjects.RegisterMultiPart( - entityId: entity.Id, - entityWorldPos: entity.Position, - entityWorldRot: entity.Rotation, - shapes: shapes, - state: state, - flags: flags, - worldOffsetX: origin.X, - worldOffsetY: origin.Y, - landblockId: spawn.Position.Value.LandblockId, - // BR-7 / A6.P4 (2026-06-11): the server position's full cell id - // is the registration flood seed (retail m_position.objcell_id - // into CObjCell::find_cell_list). A door whose spheres straddle - // the doorway lands in BOTH the outdoor landcell and the - // vestibule's shadow list at registration — the architectural - // close of #99. Dynamic objects use calc_cross_cells (no - // do_not_load prune), hence isStatic: false. - seedCellId: spawn.Position.Value.LandblockId, - isStatic: false); - - // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. - // Per-shape detail appears in [resolve-bldg] when collisions fire; - // this entity-level line keeps the spawn-time identification. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - { - int nCyl = 0, nBsp = 0; - foreach (var s in shapes) - { - if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder) nCyl++; - else nBsp++; - } - Console.WriteLine(System.FormattableString.Invariant( - $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} shapes=cyl{nCyl}+bsp{nBsp} note=server-spawn-root state=0x{state:X8} flags={flags}")); - } - } - private void RouteSameGenerationCreateObject( AcDream.App.World.SameGenerationCreateObjectEvents refresh) { @@ -4511,10 +4371,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext OnLiveAppearanceUpdated(refresh.Appearance); if (refresh.Parent is { } parent - && _liveEntities!.TryApplyCreateParent(parent, out var acceptedParent)) + && _liveEntities!.TryApplyCreateParent(parent, out _)) { - WithdrawLiveEntityWorldProjection(parent.ChildGuid); - _equippedChildRenderer?.OnSpawn(acceptedParent); + _equippedChildRenderer?.OnCreateParentAccepted(parent); } else if (refresh.Position is { } position) { @@ -4541,8 +4400,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext if (!_liveEntities!.TryApplyPickup(pickup, out _)) return; - _equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid); - bool removed = WithdrawLiveEntityWorldProjection(pickup.Guid); + AcDream.App.Rendering.ChildUnparentDisposition childDisposition = + _equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid) + ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; + bool removed = childDisposition switch + { + AcDream.App.Rendering.ChildUnparentDisposition.Completed => true, + AcDream.App.Rendering.ChildUnparentDisposition.NotAttached => true, + _ => false, + }; if (removed && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") @@ -4564,9 +4430,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update) { - if (!_liveEntities!.TryApplyParent(update, out _)) return false; - WithdrawLiveEntityWorldProjection(update.ChildGuid); - return true; + return _liveEntities!.TryApplyParent(update, out _); } private void PublishLocalPhysicsTimestamps( @@ -4596,7 +4460,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext } if (!force - && _lastLocalPlayerShadow is { } last + && _localPlayerShadow.Current is { } last && last.CellId == cellId && System.Numerics.Vector3.DistanceSquared( last.Position, playerEntity.Position) <= 1e-4f @@ -4614,7 +4478,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext cellId, _liveCenterX, _liveCenterY); - _lastLocalPlayerShadow = ( + _localPlayerShadow.Set( playerEntity.Position, playerEntity.Rotation, cellId); @@ -4631,38 +4495,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.World.WorldEntity playerEntity) { _physicsEngine.ShadowObjects.Suspend(playerEntity.Id); - _lastLocalPlayerShadow = null; - } - - /// - /// #175: the motion table's default-state pose (the closed pose for - /// doors) — the derivation lives in - /// (Core, - /// dat-conformance-tested; the first cut here used a bare-style cycle - /// key, always missed, and silently no-oped — the "175 is not fixed" - /// report). Returns null → placement-frame fallback. - /// - private IReadOnlyList? MotionTableDefaultPose( - uint motionTableId, int partCount) - { - if (motionTableId == 0u || partCount == 0 || _dats is null) return null; - - var mt = _dats.Get(motionTableId); - if (mt is null) return null; - - var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( - mt, id => _animLoader?.LoadAnimation(id)); - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - string desc = pose is null ? "null->placement-fallback" - : System.FormattableString.Invariant( - $"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})"); - Console.WriteLine(System.FormattableString.Invariant( - $"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {desc}")); - } - - return pose; + _localPlayerShadow.Clear(); } /// @@ -5067,7 +4900,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext cleanups.Add(() => incarnation.RunIfNoReplacement( () => _remoteLastMove.Remove(serverGuid))); cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188 - cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record)); + cleanups.Add(() => _liveEntityProjectionWithdrawal!.LeaveWorld( + record, + _playerServerGuid)); + cleanups.Add(() => _equippedChildRenderer?.OnLogicalTeardown(record)); // Logical teardown ends the retained shadow payload too. The weaker // pickup/parent path stops after Suspend so re-entry can restore it. cleanups.Add(() => _physicsEngine.ShadowObjects.Deregister(existingEntity.Id)); @@ -5075,56 +4911,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return new AcDream.App.World.LiveEntityTeardownPlan(cleanups); } - /// - /// Retail CPhysicsObj::leave_world (0x005155A0): remove cell/shadow - /// presentation while retaining the PartArray, MovementManager, script - /// manager, animation owner, physics host, and effect owner. Pickup and - /// ParentEvent use this weaker transition; logical delete additionally - /// executes . - /// - private void LeaveWorldLiveEntityRuntimeComponents(LiveEntityRecord record) - { - if (record.WorldEntity is not { } entity) - return; - - bool retainedProjectileShadow = - _projectileController?.LeaveWorld(record) == true; - _worldGameState.RemoveById(entity.Id); - _worldEvents.ForgetEntity(entity.Id); - if (!retainedProjectileShadow) - _physicsEngine.ShadowObjects.Deregister(entity.Id); - if (record.ServerGuid == _playerServerGuid) - { - var incarnation = new AcDream.App.World.LiveEntityIncarnationCleanup( - record, - guid => _liveEntities?.TryGetRecord(guid, out LiveEntityRecord current) == true - ? current - : null); - incarnation.RunIfNoReplacement(() => _lastLocalPlayerShadow = null); - } - - // Pose-attached effects keep logical ownership while cell-less. A - // missing pose suppresses stale anchors; re-entry republishes the same - // WorldEntity frames without replaying create-time resources. - _effectPoses.Remove(entity.Id); - } - - /// - /// Removes the world-facing projection of a still-live object. Pickup and - /// parenting advance POSITION_TS but do not end the object incarnation, so - /// the runtime identity and create-time mesh/script resources survive. - /// - private bool WithdrawLiveEntityWorldProjection(uint serverGuid) - { - if (_liveEntities is null - || !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) - || record.WorldEntity is null) - return false; - - LeaveWorldLiveEntityRuntimeComponents(record); - return _liveEntities.WithdrawLiveEntityProjection(serverGuid); - } - /// /// R4-V5: shared retail unpack_movement type-6..9 routing /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes @@ -6220,15 +6006,33 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { if (!IsCurrentPositionOwner()) return; - _equippedChildRenderer?.OnChildBecameUnparented(update.Guid); - if (!IsCurrentPositionOwner()) - return; - lock (_datLock) - OnLiveEntitySpawnedLocked( - acceptedSpawn, - LiveProjectionPurpose.SpatialRecovery); + AcDream.App.Rendering.ChildUnparentDisposition unparented = + _equippedChildRenderer?.OnChildBecameUnparented( + update.Guid, + () => + { + if (!IsCurrentPositionOwner()) + return; + lock (_datLock) + OnLiveEntitySpawnedLocked( + acceptedSpawn, + LiveProjectionPurpose.SpatialRecovery); + }) + ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; + if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded + or AcDream.App.Rendering.ChildUnparentDisposition.Pending) + return; if (!IsCurrentPositionOwner()) return; + if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached) + { + lock (_datLock) + OnLiveEntitySpawnedLocked( + acceptedSpawn, + LiveProjectionPurpose.SpatialRecovery); + if (!IsCurrentPositionOwner()) + return; + } } if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; diff --git a/src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs b/src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs new file mode 100644 index 00000000..96e156bf --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs @@ -0,0 +1,127 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Exact runtime identity retained while retail +/// SmartBox::UpdateVisualDesc mutates only a live object's visual +/// description. +/// +internal sealed record LiveEntityAppearanceUpdateState( + WorldEntity Entity, + LiveEntityAnimationState? Animation); + +internal sealed record LiveEntityAppearanceCollisionUpdate( + LiveEntityRecord Record, + WorldEntity Entity, + LiveEntityCollisionRegistration? Registration); + +/// +/// Focused appearance-lifetime operations shared by ObjDesc hydration and the +/// final animation presenter. This type owns no identity index; every capture +/// is resolved from . +/// +internal static class LiveEntityAppearanceBinding +{ + public static LiveEntityAppearanceUpdateState? Capture( + LiveEntityRuntime runtime, + uint serverGuid) + { + ArgumentNullException.ThrowIfNull(runtime); + if (!runtime.TryGetRecord(serverGuid, out LiveEntityRecord record) + || record.WorldEntity is not { } entity) + { + return null; + } + + return new LiveEntityAppearanceUpdateState( + entity, + record.AnimationRuntime as LiveEntityAnimationState); + } + + public static void RebindAnimation( + LiveEntityAnimationState animation, + WorldEntity entity, + Setup setup, + float scale, + IReadOnlyList partTemplate, + IReadOnlyList partAvailability) + { + ArgumentNullException.ThrowIfNull(animation); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(setup); + ArgumentNullException.ThrowIfNull(partTemplate); + ArgumentNullException.ThrowIfNull(partAvailability); + + animation.Entity = entity; + animation.Setup = setup; + animation.Scale = scale; + animation.PartTemplate = partTemplate; + animation.PartAvailability = partAvailability; + animation.InvalidatePresentationPoses(); + } + + public static LiveEntityAppearanceCollisionUpdate? PrepareCollision( + LiveEntityRuntime runtime, + LiveEntityCollisionBuilder builder, + WorldEntity entity, + Setup setup, + IReadOnlyList effectivePartGfxObjIds, + WorldSession.EntitySpawn spawn, + Vector3 worldOrigin) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(setup); + ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds); + if (!runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record) + || !ReferenceEquals(record.WorldEntity, entity)) + { + return null; + } + + return new LiveEntityAppearanceCollisionUpdate( + record, + entity, + builder.Build( + entity, + setup, + effectivePartGfxObjIds, + spawn, + record, + worldOrigin, + retainEmptyPayload: true)); + } + + public static bool CommitCollision( + LiveEntityRuntime runtime, + ShadowObjectRegistry registry, + LiveEntityAppearanceCollisionUpdate update) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(update); + if (!runtime.TryGetRecord(update.Record.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, update.Record) + || !ReferenceEquals(current.WorldEntity, update.Entity)) + { + return false; + } + + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, + update.Entity.Id, + update.Registration, + suspendIfNew: !current.IsSpatiallyVisible + || current.ProjectionKind is not LiveEntityProjectionKind.World + || (current.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0); + return true; + } +} diff --git a/src/AcDream.App/World/InboundPhysicsStateController.cs b/src/AcDream.App/World/InboundPhysicsStateController.cs index 58ebde3c..d39ee515 100644 --- a/src/AcDream.App/World/InboundPhysicsStateController.cs +++ b/src/AcDream.App/World/InboundPhysicsStateController.cs @@ -136,12 +136,7 @@ public sealed class InboundPhysicsStateController return false; } - accepted = ApplyParent( - child, - update.ParentGuid, - update.ParentLocation, - update.PlacementId, - update.ChildPositionSequence); + accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); _snapshots[update.ChildGuid] = accepted; return true; } @@ -160,13 +155,34 @@ public sealed class InboundPhysicsStateController return false; } + accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + _snapshots[update.ChildGuid] = accepted; + return true; + } + + public bool TryCommitParent( + uint childGuid, + uint parentGuid, + uint parentLocation, + uint placementId, + ushort positionSequence, + out WorldSession.EntitySpawn accepted) + { + if (!TryGet(childGuid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn child) + || gate.PositionTimestamp != positionSequence + || child.PositionSequence != positionSequence) + { + accepted = default; + return false; + } + accepted = ApplyParent( child, - update.ParentGuid, - update.ParentLocation, - update.PlacementId, - update.ChildPositionSequence); - _snapshots[update.ChildGuid] = accepted; + parentGuid, + parentLocation, + placementId, + positionSequence); + _snapshots[childGuid] = accepted; return true; } @@ -595,6 +611,25 @@ public sealed class InboundPhysicsStateController }; } + private static WorldSession.EntitySpawn ApplyPositionTimestampOnly( + WorldSession.EntitySpawn old, + ushort positionSequence) + { + PhysicsSpawnData? physics = old.Physics; + if (physics is { } desc) + { + physics = desc with + { + Timestamps = desc.Timestamps with { Position = positionSequence }, + }; + } + return old with + { + PositionSequence = positionSequence, + Physics = physics, + }; + } + private static WorldSession.EntitySpawn ApplyParent( WorldSession.EntitySpawn old, uint parentGuid, diff --git a/src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs b/src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs new file mode 100644 index 00000000..14d13aa4 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityProjectionWithdrawalController.cs @@ -0,0 +1,175 @@ +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; + +namespace AcDream.App.World; + +/// +/// Owns retail CPhysicsObj::leave_world's App projection tail for a +/// still-live exact record. Component projection is removed before canonical +/// runtime withdrawal, matching the shipped failure/retry boundary. +/// +internal sealed class LiveEntityProjectionWithdrawalController +{ + private readonly LiveEntityRuntime _runtime; + private readonly ProjectileController _projectiles; + private readonly WorldGameState _worldState; + private readonly WorldEvents _worldEvents; + private readonly ShadowObjectRegistry _shadows; + private readonly EntityEffectPoseRegistry _effectPoses; + private readonly LocalPlayerShadowState _localPlayerShadow; + + public LiveEntityProjectionWithdrawalController( + LiveEntityRuntime runtime, + ProjectileController projectiles, + WorldGameState worldState, + WorldEvents worldEvents, + ShadowObjectRegistry shadows, + EntityEffectPoseRegistry effectPoses, + LocalPlayerShadowState localPlayerShadow) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _projectiles = projectiles ?? throw new ArgumentNullException(nameof(projectiles)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); + _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); + _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses)); + _localPlayerShadow = localPlayerShadow + ?? throw new ArgumentNullException(nameof(localPlayerShadow)); + } + + public bool Withdraw(uint serverGuid, uint localPlayerGuid) + { + if (!_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record) + || record.WorldEntity is null) + { + return false; + } + + return Withdraw(record, localPlayerGuid); + } + + /// + /// Withdraws only the captured incarnation and projection operation. A + /// leave-world observer may accept a fresh same-generation Position and + /// reproject this record; that newer projection must survive the displaced + /// outer operation just as a newer INSTANCE_TS must. + /// + public bool Withdraw(LiveEntityRecord record, uint localPlayerGuid) + { + ArgumentNullException.ThrowIfNull(record); + if (!_runtime.IsCurrentRecord(record) || record.WorldEntity is null) + return false; + + return Withdraw( + record, + record.PositionAuthorityVersion, + record.ProjectionMutationVersion, + localPlayerGuid); + } + + public bool Withdraw( + LiveEntityRecord record, + ulong positionAuthorityVersion, + ulong projectionMutationVersion, + uint localPlayerGuid) + { + ExactProjectionWithdrawalOutcome outcome = WithdrawExact( + record, + positionAuthorityVersion, + projectionMutationVersion, + localPlayerGuid); + if (outcome.Failure is not null) + throw outcome.Failure; + return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed; + } + + public ExactProjectionWithdrawalOutcome WithdrawExact( + LiveEntityRecord record, + ulong positionAuthorityVersion, + ulong projectionMutationVersion, + uint localPlayerGuid) + { + ArgumentNullException.ThrowIfNull(record); + if (!_runtime.IsCurrentRecord(record) + || record.WorldEntity is null + || record.PositionAuthorityVersion != positionAuthorityVersion + || record.ProjectionMutationVersion != projectionMutationVersion) + { + return new( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + + try + { + LeaveWorld(record, localPlayerGuid); + bool completed = _runtime.WithdrawLiveEntityProjection( + record, + positionAuthorityVersion, + projectionMutationVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + } + catch (Exception error) + { + bool current = _runtime.IsCurrentRecord(record); + ExactProjectionWithdrawalDisposition disposition = + current + && record.PositionAuthorityVersion == positionAuthorityVersion + && record.ProjectionMutationVersion == projectionMutationVersion + && record.IsSpatiallyProjected + ? ExactProjectionWithdrawalDisposition.Pending + : current + && record.PositionAuthorityVersion == positionAuthorityVersion + && !record.IsSpatiallyProjected + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded; + return new(disposition, error); + } + } + + /// + /// Removes only the shorter world-facing component projection. This exact + /// overload is also used by logical teardown after the active GUID table + /// may already expose a replacement incarnation. + /// + public void LeaveWorld(LiveEntityRecord record, uint localPlayerGuid) + { + ArgumentNullException.ThrowIfNull(record); + if (record.WorldEntity is not { } entity) + return; + + bool retainedProjectileShadow = _projectiles.LeaveWorld(record); + if (!retainedProjectileShadow) + _shadows.Suspend(entity.Id); + _worldState.RemoveById(entity.Id); + _worldEvents.ForgetEntity(entity.Id); + if (record.ServerGuid == localPlayerGuid + && (!_runtime.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) + || ReferenceEquals(current, record))) + { + _localPlayerShadow.Clear(); + } + + // Pose-attached effects keep logical ownership while cell-less. A + // missing pose suppresses stale anchors; re-entry republishes the same + // WorldEntity frames without replaying create-time resources. + _effectPoses.Remove(entity.Id); + } +} + +public enum ExactProjectionWithdrawalDisposition +{ + Completed, + Pending, + Superseded, +} + +public readonly record struct ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition Disposition, + Exception? Failure); diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index c82d8655..e544e380 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -931,6 +931,38 @@ public sealed class LiveEntityRuntime return true; } + /// + /// Exact-incarnation withdrawal used after App leave-world callbacks. A + /// callback may accept a newer INSTANCE_TS for the same GUID; that newer + /// projection must never be withdrawn by the displaced operation. + /// + public bool WithdrawLiveEntityProjection(LiveEntityRecord expectedRecord) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + return WithdrawLiveEntityProjection( + expectedRecord, + expectedRecord.PositionAuthorityVersion, + expectedRecord.ProjectionMutationVersion); + } + + /// + /// Exact-operation withdrawal used across arbitrary App callbacks. Both + /// the accepted Position authority and projection mutation token are + /// pinned so a same-incarnation re-entry cannot be removed by an older + /// parent/pickup/leave-world operation. + /// + internal bool WithdrawLiveEntityProjection( + LiveEntityRecord expectedRecord, + ulong expectedPositionAuthorityVersion, + ulong expectedProjectionMutationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + return IsCurrentRecord(expectedRecord) + && expectedRecord.PositionAuthorityVersion == expectedPositionAuthorityVersion + && expectedRecord.ProjectionMutationVersion == expectedProjectionMutationVersion + && WithdrawLiveEntityProjection(expectedRecord.ServerGuid); + } + /// /// Withdraws a still-live projection whose authoritative rollback frame /// is outside every world cell. Logical owners remain registered. @@ -1515,7 +1547,6 @@ public sealed class LiveEntityRuntime RefreshRecord(update.ChildGuid, accepted); if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record)) record.AdvancePositionAuthority(); - ClearWorldCell(update.ChildGuid); } return applied; } @@ -1528,11 +1559,43 @@ public sealed class LiveEntityRuntime RefreshRecord(update.ChildGuid, accepted); if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record)) record.AdvancePositionAuthority(); - ClearWorldCell(update.ChildGuid); } return applied; } + internal bool CommitStagedParent( + ParentAttachmentRelation relation, + out WorldSession.EntitySpawn accepted) + { + bool committed = _inbound.TryCommitParent( + relation.ChildGuid, + relation.ParentGuid, + relation.ParentLocation, + relation.PlacementId, + relation.ChildPositionSequence, + out accepted); + if (committed) + RefreshRecord(relation.ChildGuid, accepted); + return committed; + } + + /// + /// Commits retail CPhysicsObj::set_parent's cell-less edge only + /// after the parent accepted the child (PartArray + holding-location + /// validation). The POSITION_TS is consumed before this step, matching + /// retail; invalid parent relationships retain the old world projection. + /// + internal bool CommitAcceptedParentCellless( + LiveEntityRecord record, + ulong positionAuthorityVersion) + { + ArgumentNullException.ThrowIfNull(record); + if (!IsCurrentPositionAuthority(record, positionAuthorityVersion)) + return false; + ClearWorldCell(record.ServerGuid); + return IsCurrentPositionAuthority(record, positionAuthorityVersion); + } + public bool TryApplyMotion( WorldSession.EntityMotionUpdate update, bool retainPayload, @@ -2154,7 +2217,7 @@ public sealed class LiveEntityRuntime RefreshSpatialRuntimeIndexes(record); } - private bool IsCurrentRecord(LiveEntityRecord record) => + internal bool IsCurrentRecord(LiveEntityRecord record) => _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record); diff --git a/src/AcDream.App/World/ParentAttachmentState.cs b/src/AcDream.App/World/ParentAttachmentState.cs index 04fa4e7f..029bac83 100644 --- a/src/AcDream.App/World/ParentAttachmentState.cs +++ b/src/AcDream.App/World/ParentAttachmentState.cs @@ -6,19 +6,20 @@ namespace AcDream.App.World; /// /// Update-thread state machine for parent relations that may arrive before /// either CreateObject. Unaccepted wire events remain in arrival order; -/// rollback history contains only relations that passed the canonical retail -/// timestamp gate. +/// committed rollback history contains only relations that passed both the +/// canonical retail timestamp gate and retail PartArray::add_child +/// validation. /// public sealed class ParentAttachmentState { private readonly Dictionary> _unresolvedByChild = new(); - private readonly Dictionary _projectionByChild = new(); + private readonly Dictionary _stagedByChild = new(); + private readonly Dictionary _recoveryByChild = new(); private readonly Dictionary _lastAcceptedByChild = new(); public void AcceptCreateObjectRelation(ParentAttachmentRelation relation) { - _projectionByChild[relation.ChildGuid] = relation; - _lastAcceptedByChild[relation.ChildGuid] = relation; + _stagedByChild[relation.ChildGuid] = relation; } public void Enqueue(ParentEvent.Parsed update) @@ -50,6 +51,8 @@ public sealed class ParentAttachmentState Func resolveInstance, Func accept) { + if (_stagedByChild.ContainsKey(childGuid)) + return; if (!_unresolvedByChild.TryGetValue(childGuid, out Queue? queue)) return; int candidateCount = queue.Count; @@ -95,8 +98,11 @@ public sealed class ParentAttachmentState if (!accept(update)) continue; - _projectionByChild[childGuid] = relation; - _lastAcceptedByChild[childGuid] = relation; + _stagedByChild[childGuid] = relation with + { + WaitOwner = ParentAttachmentWaitOwner.Unknown, + }; + break; } if (queue.Count == 0) @@ -104,22 +110,109 @@ public sealed class ParentAttachmentState } public bool TryGetProjection(uint childGuid, out ParentAttachmentRelation relation) => - _projectionByChild.TryGetValue(childGuid, out relation); + _stagedByChild.TryGetValue(childGuid, out relation) + || _recoveryByChild.TryGetValue(childGuid, out relation); - public void MarkProjected(uint childGuid) => _projectionByChild.Remove(childGuid); + public bool TryGetStagedProjection( + uint childGuid, + out ParentAttachmentRelation relation) => + _stagedByChild.TryGetValue(childGuid, out relation); + + public bool TryGetRecoveryProjection( + uint childGuid, + out ParentAttachmentRelation relation) => + _recoveryByChild.TryGetValue(childGuid, out relation); + + public void CopyPendingProjectionChildrenTo(List destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + foreach (uint childGuid in _stagedByChild.Keys) + destination.Add(childGuid); + foreach (uint childGuid in _recoveryByChild.Keys) + { + if (!_stagedByChild.ContainsKey(childGuid)) + destination.Add(childGuid); + } + } + + public bool IsCommitted(ParentAttachmentRelation relation) => + _lastAcceptedByChild.TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation committed) + && committed == relation; + + public bool IsPending( + ParentAttachmentRelation relation, + ParentProjectionCandidateKind kind) => + (kind is ParentProjectionCandidateKind.Staged + ? _stagedByChild + : _recoveryByChild).TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation pending) + && pending == relation; + + public void MarkProjected( + ParentAttachmentRelation relation, + ParentProjectionCandidateKind kind) + { + Dictionary source = + kind is ParentProjectionCandidateKind.Staged + ? _stagedByChild + : _recoveryByChild; + if (source.TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation pending) + && pending == relation) + { + source.Remove(relation.ChildGuid); + } + } + + public bool CommitProjection(ParentAttachmentRelation relation) + { + if (!_stagedByChild.TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation staged) + || staged != relation) + { + return false; + } + _lastAcceptedByChild[relation.ChildGuid] = relation; + _stagedByChild.Remove(relation.ChildGuid); + _recoveryByChild[relation.ChildGuid] = relation; + return true; + } + + public void RejectProjection(ParentAttachmentRelation relation) + { + if (_stagedByChild.TryGetValue( + relation.ChildGuid, + out ParentAttachmentRelation staged) + && staged == relation) + { + _stagedByChild.Remove(relation.ChildGuid); + } + } public bool RestoreLastAccepted(uint childGuid) { if (!_lastAcceptedByChild.TryGetValue(childGuid, out ParentAttachmentRelation relation)) return false; - _projectionByChild[childGuid] = relation; + _recoveryByChild[childGuid] = relation; return true; } public IReadOnlyList ChildrenWaitingForParent(uint parentGuid) { var result = new HashSet(); - foreach ((uint childGuid, ParentAttachmentRelation relation) in _projectionByChild) + foreach ((uint childGuid, ParentAttachmentRelation relation) in _stagedByChild) + { + if (relation.ParentGuid == parentGuid) + result.Add(childGuid); + } + + foreach ((uint childGuid, ParentAttachmentRelation relation) in _recoveryByChild) { if (relation.ParentGuid == parentGuid) result.Add(childGuid); @@ -136,11 +229,13 @@ public sealed class ParentAttachmentState public void RemoveObject(uint guid) { - _projectionByChild.Remove(guid); + _stagedByChild.Remove(guid); + _recoveryByChild.Remove(guid); _lastAcceptedByChild.Remove(guid); _unresolvedByChild.Remove(guid); - RemoveParentReferences(_projectionByChild, guid); + RemoveParentReferences(_stagedByChild, guid); + RemoveParentReferences(_recoveryByChild, guid); RemoveParentReferences(_lastAcceptedByChild, guid); uint[] children = _unresolvedByChild.Keys.ToArray(); @@ -165,9 +260,11 @@ public sealed class ParentAttachmentState FilterChildCandidates( guid, relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); - _projectionByChild.Remove(guid); + _stagedByChild.Remove(guid); + _recoveryByChild.Remove(guid); _lastAcceptedByChild.Remove(guid); - RemoveParentReferences(_projectionByChild, guid); + RemoveParentReferences(_stagedByChild, guid); + RemoveParentReferences(_recoveryByChild, guid); RemoveParentReferences(_lastAcceptedByChild, guid); FilterParentCandidates( guid, @@ -187,9 +284,11 @@ public sealed class ParentAttachmentState FilterChildCandidates( guid, relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); - _projectionByChild.Remove(guid); + _stagedByChild.Remove(guid); + _recoveryByChild.Remove(guid); _lastAcceptedByChild.Remove(guid); - RemoveParentReferences(_projectionByChild, guid); + RemoveParentReferences(_stagedByChild, guid); + RemoveParentReferences(_recoveryByChild, guid); RemoveParentReferences(_lastAcceptedByChild, guid); FilterParentCandidates( guid, @@ -204,13 +303,15 @@ public sealed class ParentAttachmentState /// public void EndChildProjection(uint childGuid) { - _projectionByChild.Remove(childGuid); + _stagedByChild.Remove(childGuid); + _recoveryByChild.Remove(childGuid); _lastAcceptedByChild.Remove(childGuid); } public void RemoveChild(uint childGuid) { - _projectionByChild.Remove(childGuid); + _stagedByChild.Remove(childGuid); + _recoveryByChild.Remove(childGuid); _lastAcceptedByChild.Remove(childGuid); _unresolvedByChild.Remove(childGuid); } @@ -218,7 +319,8 @@ public sealed class ParentAttachmentState public void Clear() { _unresolvedByChild.Clear(); - _projectionByChild.Clear(); + _stagedByChild.Clear(); + _recoveryByChild.Clear(); _lastAcceptedByChild.Clear(); } @@ -286,3 +388,9 @@ public enum ParentAttachmentWaitOwner Parent, Child, } + +public enum ParentProjectionCandidateKind +{ + Staged, + Recovery, +} diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 957d5525..bafdbdd7 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -216,6 +216,92 @@ public sealed class ShadowObjectRegistry CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f); } + /// + /// Replaces an existing live PartArray collision payload in its current + /// shadow-cell membership. Retail CPartArray::SetPart changes the + /// part read by later collision tests; it does not recalculate cross-cells. + /// A suspended owner updates only its retained payload. A newly shaped + /// owner may perform its first flood, then immediately suspend when its + /// canonical projection is Hidden, attached, or cell-less. + /// + public void ReplaceMultiPartPayload( + uint entityId, + Vector3 entityWorldPos, + Quaternion entityWorldRot, + System.Collections.Generic.IReadOnlyList shapes, + uint state, + EntityCollisionFlags flags, + float worldOffsetX, + float worldOffsetY, + uint landblockId, + uint seedCellId = 0u, + bool isStatic = false, + bool suspendIfNew = false) + { + if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior) + || !prior.IsMultiPart) + { + if (shapes.Count == 0) + return; + RegisterMultiPart( + entityId, + entityWorldPos, + entityWorldRot, + shapes, + state, + flags, + worldOffsetX, + worldOffsetY, + landblockId, + seedCellId, + isStatic); + if (suspendIfNew) + Suspend(entityId); + return; + } + + bool suspended = _suspendedEntities.Contains(entityId); + _entityShapes[entityId] = shapes; + _entityReg[entityId] = prior with + { + EntityWorldPos = entityWorldPos, + EntityWorldRot = entityWorldRot, + State = state, + Flags = flags, + }; + + if (suspended || !_entityToCells.TryGetValue(entityId, out List? cells)) + return; + + foreach (uint cellId in cells) + { + if (_cells.TryGetValue(cellId, out List? entries)) + entries.RemoveAll(entry => entry.EntityId == entityId); + } + + foreach (ShadowShape shape in shapes) + { + Vector3 partWorldPos = entityWorldPos + + Vector3.Transform(shape.LocalPosition, entityWorldRot); + Quaternion partWorldRot = entityWorldRot * shape.LocalRotation; + var entry = new ShadowEntry( + EntityId: entityId, + GfxObjId: shape.GfxObjId, + Position: partWorldPos, + Rotation: partWorldRot, + Radius: shape.Radius, + CollisionType: shape.CollisionType, + CylHeight: shape.CylHeight, + Scale: shape.Scale, + State: state, + Flags: flags, + LocalPosition: shape.LocalPosition, + LocalRotation: shape.LocalRotation); + foreach (uint cellId in cells) + AddEntryToCell(entry, cellId); + } + } + /// /// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0): /// when the object has cylinder shapes, each contributes one sphere at diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index a0e28ff3..fcf3ec80 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -50,11 +50,16 @@ public static class ShadowShapeBuilder /// placement frame per part (entities with no motion table, and the /// CylSphere/Sphere shapes, are unaffected — retail poses those from /// the setup too). + /// Current part identities after + /// retail AnimPartChanged processing. Collision keeps each Setup + /// index and pose, but reads PhysicsBSP from the installed replacement. + /// Null or short lists fall back to the Setup identity. public static IReadOnlyList FromSetup( Setup setup, float entScale, Func hasPhysicsBsp, - IReadOnlyList? partPoseOverride = null) + IReadOnlyList? partPoseOverride = null, + IReadOnlyList? effectivePartGfxObjIds = null) { if (setup is null) throw new ArgumentNullException(nameof(setup)); if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); @@ -102,7 +107,14 @@ public static class ShadowShapeBuilder AnimationFrame? placementFrame = ResolvePlacementFrame(setup); for (int i = 0; i < setup.Parts.Count; i++) { - uint gfxId = (uint)setup.Parts[i]; + // Retail CPhysicsPart::SetPart installs AnimPartChanged's current + // degrade array before CPartArray::FindObjCollisions reads it. + // Keep the stable Setup part index/pose, but source collision + // identity from that effective part when one was supplied. + uint gfxId = effectivePartGfxObjIds is not null + && i < effectivePartGfxObjIds.Count + ? effectivePartGfxObjIds[i] + : (uint)setup.Parts[i]; if (!hasPhysicsBsp(gfxId)) continue; Frame partFrame; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 45f4e278..d39421ed 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3505,7 +3505,7 @@ public sealed class Transition /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip records the /// center-to-center collision normal and hard-stops (the M1.5 load-bearing /// path — players never set PerfectClip). PerfectClip gets the exact - /// time-of-impact reposition (missiles only — AP-84, dead in M1.5, ported per + /// time-of-impact reposition (missiles only — AP-91, dead in M1.5, ported per /// ACE Sphere.cs:175-210; re-verify vs Ghidra before missiles ship). /// private TransitionState SphereCollideWithPoint(ShadowEntry obj, SpherePath sp, @@ -3521,7 +3521,7 @@ public sealed class Transition return TransitionState.Collided; } - // PerfectClip exact time-of-impact (AP-84 — dead in M1.5). Block offset = 0. + // PerfectClip exact time-of-impact (AP-91 — dead in M1.5). Block offset = 0. Vector3 checkOffset = checkSphere.Origin - gCenter; double toi = FindSphereTimeOfCollision(checkOffset, globalOffset, radsum + PhysicsGlobals.EPSILON); if (toi < PhysicsGlobals.EPSILON || toi > 1.0) diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs new file mode 100644 index 00000000..e0648148 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs @@ -0,0 +1,358 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Physics; + +public sealed class LiveEntityCollisionBuilderTests +{ + private const uint Guid = 0x70000100u; + private const uint Cell = 0x01010001u; + + [Fact] + public void EffectivePartResolution_IsSetupAndVisualOptionIndependent() + { + uint[] resolved = LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( + [0x01001000u, 0x01002000u], + id => id == 0x01001000u ? 0x01001001u : id); + + Assert.Equal([0x01001001u, 0x01002000u], resolved); + } + + [Fact] + public void RadiusFallback_UsesExactStateFlagsScaleAndFullSeedCell() + { + var setup = new Setup { Radius = 0.75f, Height = 2.5f }; + WorldSession.EntitySpawn spawn = Spawn( + scale: 2f, + itemType: (uint)ItemType.Creature, + descriptionFlags: 0x28u); + var record = new LiveEntityRecord(spawn) + { + FinalPhysicsState = PhysicsStateFlags.Hidden | PhysicsStateFlags.Static, + }; + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = Builder(); + + LiveEntityCollisionRegistration registration = Assert.IsType( + builder.Build(entity, setup, Array.Empty(), spawn, record, new Vector3(192f, -192f, 0f))); + + ShadowShape shape = Assert.Single(registration.Shapes); + Assert.Equal(ShadowCollisionType.Cylinder, shape.CollisionType); + Assert.Equal(1.5f, shape.Radius); + Assert.Equal(5f, shape.CylHeight); + Assert.Equal((uint)record.FinalPhysicsState, registration.State); + Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.HasWeenie)); + Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsCreature)); + Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsPlayer)); + Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsPK)); + Assert.Equal(Cell, registration.LandblockId); + Assert.Equal(Cell, registration.SeedCellId); + Assert.Equal((192f, -192f), (registration.WorldOffsetX, registration.WorldOffsetY)); + } + + [Fact] + public void BspOnlyPart_UsesRealScaledPhysicsBoundingRadius() + { + const uint part = 0x0100ABCDu; + var setup = new Setup(); + setup.Parts.Add(part); + WorldSession.EntitySpawn spawn = Spawn(scale: 1.5f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == part, + id => id == part ? 3f : null, + PoseResolver()); + + LiveEntityCollisionRegistration registration = Assert.IsType( + builder.Build(entity, setup, [part], spawn, record, Vector3.Zero)); + + ShadowShape shape = Assert.Single(registration.Shapes); + Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType); + Assert.Equal(4.5f, shape.Radius); + Assert.Equal(part, shape.GfxObjId); + } + + [Fact] + public void EffectiveReplacementWithoutPhysicsBsp_RemovesBasePartCollision() + { + const uint basePart = 0x0100AB01u; + const uint replacement = 0x0100AB02u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == basePart, + _ => 1f, + PoseResolver()); + + Assert.Null(builder.Build( + entity, + setup, + [replacement], + spawn, + record, + Vector3.Zero)); + } + + [Fact] + public void EffectiveReplacementWithPhysicsBsp_AddsReplacementCollision() + { + const uint basePart = 0x0100AB11u; + const uint replacement = 0x0100AB12u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == replacement, + id => id == replacement ? 2.25f : null, + PoseResolver()); + + LiveEntityCollisionRegistration registration = + Assert.IsType(builder.Build( + entity, + setup, + [replacement], + spawn, + record, + Vector3.Zero)); + + ShadowShape shape = Assert.Single(registration.Shapes); + Assert.Equal(replacement, shape.GfxObjId); + Assert.Equal(2.25f, shape.Radius); + } + + [Fact] + public void ReconcileAppearance_VisibleReplacementWithoutBspRemovesOldPayload() + { + const uint basePart = 0x0100AC01u; + const uint replacement = 0x0100AC02u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == basePart, + _ => 1f, + PoseResolver()); + LiveEntityCollisionRegistration initial = + Assert.IsType(builder.Build( + entity, setup, [basePart], spawn, record, Vector3.Zero)); + var registry = new ShadowObjectRegistry(); + LiveEntityCollisionBuilder.Register(registry, initial); + + LiveEntityCollisionRegistration? changed = builder.Build( + entity, setup, [replacement], spawn, record, Vector3.Zero, + retainEmptyPayload: true); + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, changed, suspendIfNew: false); + + Assert.Equal(1, registry.RetainedRegistrationCount); + Assert.DoesNotContain( + registry.GetObjectsInCell(Cell), + entry => entry.EntityId == entity.Id); + } + + [Fact] + public void ReconcileAppearance_SuspendedReplacementRetainsNewPayloadUntilRestore() + { + const uint basePart = 0x0100AC11u; + const uint replacement = 0x0100AC12u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == basePart || id == replacement, + _ => 1f, + PoseResolver()); + LiveEntityCollisionRegistration initial = + Assert.IsType(builder.Build( + entity, setup, [basePart], spawn, record, Vector3.Zero)); + var registry = new ShadowObjectRegistry(); + LiveEntityCollisionBuilder.Register(registry, initial); + Assert.True(registry.Suspend(entity.Id)); + LiveEntityCollisionRegistration changed = + Assert.IsType(builder.Build( + entity, setup, [replacement], spawn, record, Vector3.Zero)); + + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, changed, suspendIfNew: true); + + Assert.Equal(1, registry.RetainedRegistrationCount); + Assert.Equal(1, registry.SuspendedRegistrationCount); + Assert.DoesNotContain( + registry.GetObjectsInCell(Cell), + entry => entry.EntityId == entity.Id); + + LiveEntityCollisionRegistration empty = + Assert.IsType(builder.Build( + entity, + setup, + [0x0100AC13u], + spawn, + record, + Vector3.Zero, + retainEmptyPayload: true)); + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, empty, suspendIfNew: true); + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, changed, suspendIfNew: true); + Assert.Equal(1, registry.RetainedRegistrationCount); + Assert.Equal(1, registry.SuspendedRegistrationCount); + + registry.UpdatePosition( + entity.Id, + entity.Position, + entity.Rotation, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Cell, + seedCellId: Cell); + ShadowEntry restored = Assert.Single( + registry.GetObjectsInCell(Cell), + entry => entry.EntityId == entity.Id); + Assert.Equal(replacement, restored.GfxObjId); + Assert.Equal(0, registry.SuspendedRegistrationCount); + } + + [Fact] + public void ReconcileAppearance_ExistingMembershipDoesNotDependOnNewFlood() + { + const uint basePart = 0x0100AC21u; + const uint replacement = 0x0100AC22u; + var setup = new Setup(); + setup.Parts.Add(basePart); + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + var builder = new LiveEntityCollisionBuilder( + id => id == basePart || id == replacement, + _ => 1f, + PoseResolver()); + LiveEntityCollisionRegistration initial = + Assert.IsType(builder.Build( + entity, setup, [basePart], spawn, record, Vector3.Zero)); + var registry = new ShadowObjectRegistry(); + LiveEntityCollisionBuilder.Register(registry, initial); + LiveEntityCollisionRegistration changed = + Assert.IsType(builder.Build( + entity, setup, [replacement], spawn, record, Vector3.Zero)) + with + { + LandblockId = 0u, + SeedCellId = 0u, + }; + + LiveEntityCollisionBuilder.ReconcileAppearance( + registry, entity.Id, changed, suspendIfNew: false); + + ShadowEntry entry = Assert.Single( + registry.GetObjectsInCell(Cell), + candidate => candidate.EntityId == entity.Id); + Assert.Equal(replacement, entry.GfxObjId); + } + + [Fact] + public void ShapelessSetup_ProducesNoRegistration() + { + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var record = new LiveEntityRecord(spawn); + WorldEntity entity = Entity(); + record.WorldEntity = entity; + + Assert.Null(Builder().Build( + entity, + new Setup(), + Array.Empty(), + spawn, + record, + Vector3.Zero)); + } + + [Fact] + public void Build_RejectsDifferentRecordIncarnation() + { + WorldSession.EntitySpawn spawn = Spawn(scale: 1f); + var expected = new LiveEntityRecord(spawn); + var other = new LiveEntityRecord(spawn with { InstanceSequence = 2 }); + WorldEntity entity = Entity(); + expected.WorldEntity = entity; + other.WorldEntity = entity; + + Assert.Throws(() => Builder().Build( + entity, + new Setup { Radius = 1f }, + Array.Empty(), + spawn, + other, + Vector3.Zero)); + } + + private static LiveEntityCollisionBuilder Builder() => new( + _ => false, + _ => null, + PoseResolver()); + + private static LiveEntityDefaultPoseResolver PoseResolver() => new( + _ => null, + new NullAnimationLoader(), + dumpMotion: false); + + private static WorldEntity Entity() => new() + { + Id = 101u, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000100u, + Position = new Vector3(4f, 5f, 6f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + }; + + private static WorldSession.EntitySpawn Spawn( + float scale, + uint? itemType = null, + uint? descriptionFlags = null) => + new( + Guid, + new CreateObject.ServerPosition(Cell, 4f, 5f, 6f, 1f, 0f, 0f, 0f), + 0x02000100u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: scale, + Name: "collision fixture", + ItemType: itemType, + MotionState: null, + MotionTableId: null, + PhysicsState: 0u, + ObjectDescriptionFlags: descriptionFlags, + InstanceSequence: 1); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs new file mode 100644 index 00000000..5f1bf6e3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -0,0 +1,1151 @@ +using System.Collections; +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Rendering; + +public sealed class EquippedChildProjectionWithdrawalTests +{ + [Fact] + public void WithdrawalFailure_DoesNotPublishAttachedProjectionRemoval() + { + bool published = false; + + ExactProjectionWithdrawalOutcome outcome = + EquippedChildRenderController.WithdrawAttachedProjection( + ChildRecord(), + positionAuthorityVersion: 1, + projectionMutationVersion: 2, + (_, _, _) => new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("injected component cleanup failure")), + () => + { + published = true; + return true; + }); + + Assert.IsType(outcome.Failure); + Assert.Equal(ExactProjectionWithdrawalDisposition.Pending, outcome.Disposition); + Assert.False(published); + } + + [Fact] + public void ExactIncarnationRejection_DoesNotPublishAttachedProjectionRemoval() + { + bool published = false; + + ExactProjectionWithdrawalOutcome outcome = EquippedChildRenderController.WithdrawAttachedProjection( + ChildRecord(), + positionAuthorityVersion: 1, + projectionMutationVersion: 2, + (_, _, _) => new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Superseded, + Failure: null), + () => + { + published = true; + return true; + }); + + Assert.True(published); + Assert.Equal(ExactProjectionWithdrawalDisposition.Superseded, outcome.Disposition); + } + + [Fact] + public void SuccessfulWithdrawal_PublishesLocalProjectionRemovalOnce() + { + int publications = 0; + + ExactProjectionWithdrawalOutcome outcome = EquippedChildRenderController.WithdrawAttachedProjection( + ChildRecord(), + positionAuthorityVersion: 1, + projectionMutationVersion: 2, + (_, _, _) => new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null), + () => + { + publications++; + return true; + }); + + Assert.Equal(ExactProjectionWithdrawalDisposition.Completed, outcome.Disposition); + Assert.Equal(1, publications); + } + + [Fact] + public void LogicalReplacement_RemovesExactAttachedMapWithoutWithdrawingReplacement() + { + int withdrawals = 0; + using var fixture = new ControllerFixture( + (_, _, _) => + { + withdrawals++; + return new( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null); + }); + LiveEntityRecord parent = fixture.Spawn(0x70000200u, generation: 1); + LiveEntityRecord oldChild = fixture.Spawn( + 0x70000201u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, oldChild); + + LiveEntityRecord replacement = fixture.Live.RegisterLiveEntity( + ControllerFixture.SpawnData(0x70000201u, generation: 2)).Record!; + + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.True(fixture.Live.TryGetRecord(0x70000201u, out LiveEntityRecord current)); + Assert.Same(replacement, current); + Assert.Equal(0, withdrawals); + } + + [Fact] + public void LogicalDelete_NotificationFailureLeavesRetryableTombstoneButNoAttachedLeak() + { + using var fixture = new ControllerFixture((_, _, _) => + new(ExactProjectionWithdrawalDisposition.Completed, Failure: null)); + LiveEntityRecord parent = fixture.Spawn(0x70000210u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000211u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.Controller.ProjectionRemoved += Throw; + + Assert.Throws(() => fixture.Live.UnregisterLiveEntity( + new DeleteObject.Parsed(0x70000211u, InstanceSequence: 1), + isLocalPlayer: false)); + Assert.Empty(fixture.Controller.AttachedEntityIds); + + fixture.Controller.ProjectionRemoved -= Throw; + Assert.Equal(1, fixture.Live.RetryPendingTeardowns()); + + static void Throw(uint _) => + throw new InvalidOperationException("injected projection observer failure"); + } + + [Fact] + public void PostCommitWithdrawalFailure_RetiresCapturedAttachedMap() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool committed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + Assert.True(committed); + return new( + ExactProjectionWithdrawalDisposition.Completed, + new InvalidOperationException("observer failed after commit")); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000220u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000221u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + + Assert.Throws(() => + fixture.Controller.OnChildBecameUnparented(0x70000221u)); + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.False(child.IsSpatiallyProjected); + } + } + + [Fact] + public void PendingTopLevelWithdrawal_RetryRunsAcceptedContinuationOnce() + { + int attempts = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + attempts++; + if (attempts == 1) + { + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("component cleanup failed")); + } + bool committed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + committed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord record = fixture.Spawn(0x70000230u, generation: 1); + int continuations = 0; + Assert.Throws(() => + fixture.Controller.OnChildBecameUnparented( + 0x70000230u, + () => + { + continuations++; + Assert.True(fixture.Live.RebucketLiveEntity( + 0x70000230u, + 0x01010001u)); + })); + + fixture.Controller.Tick(); + + Assert.Equal(2, attempts); + Assert.Equal(1, continuations); + Assert.True(record.IsSpatiallyProjected); + } + } + + [Fact] + public void PendingUnparent_NewerPositionSupersedesRecoveryContinuation() + { + using var fixture = new ControllerFixture((_, _, _) => + new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("component cleanup failed"))); + LiveEntityRecord record = fixture.Spawn(0x70000240u, generation: 1); + int continuations = 0; + Assert.Throws(() => + fixture.Controller.OnChildBecameUnparented( + 0x70000240u, + () => continuations++)); + + var newer = new WorldSession.EntityPositionUpdate( + 0x70000240u, + new CreateObject.ServerPosition( + 0x01010001u, 4f, 5f, 6f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0); + Assert.True(fixture.Live.TryApplyPosition( + newer, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + + fixture.Controller.Tick(); + + Assert.Equal(0, continuations); + Assert.True(record.IsSpatiallyProjected); + } + + [Fact] + public void AcceptedValidParent_WithdrawsWorldProjectionBeforePosePrerequisitesExist() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + fixture.Spawn(0x70000250u, generation: 1); + LiveEntityRecord child = fixture.Spawn(0x70000251u, generation: 1); + + fixture.Controller.OnParentEvent(new ParentEvent.Parsed( + ParentGuid: 0x70000250u, + ChildGuid: 0x70000251u, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 2)); + + Assert.False(child.IsSpatiallyProjected); + Assert.True(fixture.Live.ParentAttachments.TryGetProjection( + 0x70000251u, + out _)); + } + } + + [Fact] + public void InvalidParent_ConsumesPositionTimestampButPreservesWorldProjection() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + fixture.Spawn(0x70000252u, generation: 1); + LiveEntityRecord child = fixture.Spawn(0x70000253u, generation: 1); + + fixture.Controller.OnParentEvent(new ParentEvent.Parsed( + ParentGuid: 0x70000252u, + ChildGuid: 0x70000253u, + ParentLocation: 1, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 2)); + + Assert.True(child.IsSpatiallyProjected); + Assert.NotEqual(0u, child.FullCellId); + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.NotNull(snapshot.Position); + Assert.Null(snapshot.ParentGuid); + Assert.Equal((ushort)2, snapshot.PositionSequence); + Assert.False(fixture.Live.ParentAttachments.TryGetProjection( + child.ServerGuid, + out _)); + } + } + + [Fact] + public void QueuedValidThenInvalidParent_LeavesValidParentCommitted() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord validParent = fixture.Spawn(0x70000259u, generation: 1); + LiveEntityRecord invalidParent = fixture.Spawn(0x7000025Au, generation: 1); + const uint childGuid = 0x7000025Bu; + fixture.Controller.OnParentEvent(new ParentEvent.Parsed( + validParent.ServerGuid, childGuid, 0, 0, 1, 1)); + fixture.Controller.OnParentEvent(new ParentEvent.Parsed( + invalidParent.ServerGuid, childGuid, 1, 0, 1, 2)); + + LiveEntityRecord child = fixture.RegisterOnly( + childGuid, + generation: 1, + hasPosition: true); + fixture.Materialize(child); + Assert.True(fixture.Live.TryGetSnapshot( + childGuid, + out WorldSession.EntitySpawn childSpawn)); + fixture.Controller.OnSpawn(childSpawn); + + Assert.True(fixture.Live.TryGetSnapshot(childGuid, out childSpawn)); + Assert.Equal(validParent.ServerGuid, childSpawn.ParentGuid); + Assert.Null(childSpawn.Position); + Assert.Equal((ushort)2, childSpawn.PositionSequence); + var valid = new ParentAttachmentRelation( + validParent.ServerGuid, childGuid, 0, 0, 1, 1); + Assert.True(fixture.Live.ParentAttachments.IsCommitted(valid)); + Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection( + childGuid, + out _)); + } + } + + [Fact] + public void NoPositionCreateParent_CommitsAfterParentPartArrayValidation() + { + using var fixture = new ControllerFixture((_, _, _) => + new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null)); + LiveEntityRecord parent = fixture.Spawn(0x70000254u, generation: 1); + LiveEntityRecord child = fixture.RegisterOnly( + 0x70000255u, + generation: 1, + hasPosition: false); + var update = new CreateParentUpdate( + child.ServerGuid, + parent.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ChildInstanceSequence: 1, + ChildPositionSequence: 1); + + Assert.True(fixture.Live.TryApplyCreateParent(update, out _)); + fixture.Controller.OnCreateParentAccepted(update); + + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Equal(parent.ServerGuid, snapshot.ParentGuid); + Assert.Null(snapshot.Position); + Assert.Null(child.WorldEntity); + var relation = new ParentAttachmentRelation( + parent.ServerGuid, + child.ServerGuid, + 0, + 0, + 0, + 1); + Assert.True(fixture.Live.ParentAttachments.IsCommitted(relation)); + + fixture.Poses.Publish( + parent.WorldEntity!, + Array.Empty()); + fixture.Controller.OnPosePublished(parent.ServerGuid); + + Assert.NotNull(child.WorldEntity); + Assert.True(child.IsSpatiallyProjected); + Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind); + } + + [Fact] + public void ChildWithoutSetup_StillCommitsLogicalParenting() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x7000025Cu, generation: 1); + LiveEntityRecord child = fixture.RegisterOnly( + 0x7000025Du, + generation: 1, + hasPosition: true, + hasSetup: false); + fixture.Materialize(child); + child.HasPartArray = false; + var update = new ParentEvent.Parsed( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1); + + fixture.Controller.OnParentEvent(update); + + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Equal(parent.ServerGuid, snapshot.ParentGuid); + Assert.Null(snapshot.Position); + Assert.False(child.IsSpatiallyProjected); + Assert.True(fixture.Live.ParentAttachments.IsCommitted(new( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1))); + } + } + + [Fact] + public void EmbeddedSameGenerationCreateParent_UsesStagedRoute() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x7000025Eu, generation: 1); + LiveEntityRecord child = fixture.Spawn(0x7000025Fu, generation: 1); + PhysicsTimestamps timestamps = new( + Position: 1, + Movement: 0, + State: 0, + Vector: 0, + Teleport: 0, + ServerControlledMove: 0, + ForcePosition: 0, + ObjDesc: 0, + Instance: 1); + PhysicsSpawnData physics = new( + RawState: 0x408u, + Position: null, + Movement: null, + AnimationFrame: 0, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: new PhysicsAttachment(parent.ServerGuid, 0), + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + WorldSession.EntitySpawn incoming = ControllerFixture.SpawnData( + child.ServerGuid, + generation: 1) with + { + Position = null, + PositionSequence = 1, + PlacementId = 0, + Physics = physics, + }; + + LiveEntityRegistrationResult refresh = fixture.Live.RegisterLiveEntity(incoming); + CreateParentUpdate update = Assert.IsType( + refresh.Inbound.SameGenerationEvents!.Value.Parent); + Assert.True(fixture.Live.TryApplyCreateParent(update, out _)); + fixture.Controller.OnCreateParentAccepted(update); + + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Equal(parent.ServerGuid, snapshot.ParentGuid); + Assert.Null(snapshot.Position); + } + } + + [Fact] + public void NewWaitingParent_DoesNotDisplaceCommittedRecovery() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord oldParent = fixture.Spawn(0x70000256u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000257u, + generation: 1, + LiveEntityProjectionKind.Attached); + LiveEntityRecord waitingParent = fixture.RegisterOnly( + 0x70000258u, + generation: 1, + hasPosition: true); + var oldRelation = new ParentAttachmentRelation( + oldParent.ServerGuid, + child.ServerGuid, + 0, + 0, + 1, + 1); + fixture.Live.ParentAttachments.AcceptCreateObjectRelation(oldRelation); + Assert.True(fixture.Live.TryApplyParent(new ParentEvent.Parsed( + oldRelation.ParentGuid, + oldRelation.ChildGuid, + oldRelation.ParentLocation, + oldRelation.PlacementId, + oldRelation.ParentInstanceSequence, + oldRelation.ChildPositionSequence), out _)); + Assert.True(fixture.Live.CommitStagedParent(oldRelation, out _)); + Assert.True(fixture.Live.ParentAttachments.CommitProjection(oldRelation)); + fixture.Live.ParentAttachments.MarkProjected( + oldRelation, + ParentProjectionCandidateKind.Recovery); + fixture.InstallAttached(oldParent, child); + + var newer = new ParentEvent.Parsed( + waitingParent.ServerGuid, + child.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 2); + fixture.Controller.OnParentEvent(newer); + Assert.True(fixture.Live.ParentAttachments.TryGetStagedProjection( + child.ServerGuid, + out ParentAttachmentRelation staged)); + Assert.Equal(waitingParent.ServerGuid, staged.ParentGuid); + + fixture.Controller.Tick(); + + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.True(fixture.Live.ParentAttachments.TryGetStagedProjection( + child.ServerGuid, + out staged)); + Assert.Equal(waitingParent.ServerGuid, staged.ParentGuid); + Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection( + child.ServerGuid, + out ParentAttachmentRelation recovery)); + Assert.Equal(oldParent.ServerGuid, recovery.ParentGuid); + + fixture.Materialize(waitingParent); + fixture.Controller.OnWorldEntityRegistered(waitingParent.ServerGuid); + + Assert.True(fixture.Live.ParentAttachments.IsCommitted(staged)); + Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection( + child.ServerGuid, + out ParentAttachmentRelation committedRecovery)); + Assert.Equal(staged, committedRecovery); + Assert.True(fixture.Live.TryGetSnapshot( + child.ServerGuid, + out WorldSession.EntitySpawn snapshot)); + Assert.Equal(waitingParent.ServerGuid, snapshot.ParentGuid); + } + } + + [Fact] + public void OrdinaryRemoval_PendingFailureRetriesExactCaptureOnTick() + { + int attempts = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + attempts++; + if (attempts == 1) + { + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("ordinary cleanup failed")); + } + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000260u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000261u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.Live.ParentAttachments.AcceptCreateObjectRelation(new( + parent.ServerGuid, + child.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 1)); + + TargetInvocationException error = Assert.Throws(() => + fixture.InvokeOrdinaryRemoval(0x70000261u)); + Assert.IsType(error.InnerException); + + fixture.Controller.Tick(); + + Assert.Equal(2, attempts); + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.False(child.IsSpatiallyProjected); + Assert.False(fixture.Live.ParentAttachments.TryGetProjection( + child.ServerGuid, + out _)); + } + } + + [Fact] + public void DetachedRemoval_PendingFailureRetriesAndPreservesRollbackHistory() + { + int attempts = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + attempts++; + if (attempts == 1) + { + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("detached cleanup failed")); + } + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000262u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000263u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + var relation = new ParentAttachmentRelation( + parent.ServerGuid, + child.ServerGuid, + ParentLocation: 0, + PlacementId: 0, + ParentInstanceSequence: 1, + ChildPositionSequence: 1); + fixture.Live.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(fixture.Live.ParentAttachments.CommitProjection(relation)); + fixture.Live.ParentAttachments.MarkProjected( + relation, + ParentProjectionCandidateKind.Recovery); + + TargetInvocationException error = Assert.Throws(() => + fixture.InvokeDetachedRemoval(child.ServerGuid)); + Assert.IsType(error.InnerException); + + fixture.Controller.Tick(); + + Assert.Equal(2, attempts); + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.False(child.IsSpatiallyProjected); + Assert.True(fixture.Live.ParentAttachments.RestoreLastAccepted( + child.ServerGuid)); + Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection( + child.ServerGuid, + out ParentAttachmentRelation restored)); + Assert.Equal(relation, restored); + } + } + + [Fact] + public void Unparent_WithdrawsCompleteAttachedSubtreeAndRecoversDescendants() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord parent = fixture.Spawn(0x70000264u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000265u, + generation: 1, + LiveEntityProjectionKind.Attached); + LiveEntityRecord grandchild = fixture.Spawn( + 0x70000266u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.InstallAttached(child, grandchild); + var childRelation = new ParentAttachmentRelation( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1); + var grandchildRelation = new ParentAttachmentRelation( + child.ServerGuid, grandchild.ServerGuid, 0, 0, 1, 1); + fixture.CommitRenderedRelation(childRelation); + fixture.CommitRenderedRelation(grandchildRelation); + + ChildUnparentDisposition result = + fixture.Controller.OnChildBecameUnparented(child.ServerGuid); + + Assert.Equal(ChildUnparentDisposition.Completed, result); + Assert.Empty(fixture.Controller.AttachedEntityIds); + Assert.False(child.IsSpatiallyProjected); + Assert.False(grandchild.IsSpatiallyProjected); + Assert.False(fixture.Live.ParentAttachments.RestoreLastAccepted( + child.ServerGuid)); + Assert.True(fixture.Live.ParentAttachments.TryGetRecoveryProjection( + grandchild.ServerGuid, + out ParentAttachmentRelation recovered)); + Assert.Equal(grandchildRelation, recovered); + } + } + + [Fact] + public void PendingDetachedRemoval_RollbackCancelsRetryAndKeepsProjection() + { + int attempts = 0; + using var fixture = new ControllerFixture((_, _, _) => + { + attempts++; + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("component withdrawal failed")); + }); + LiveEntityRecord parent = fixture.Spawn(0x70000267u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000268u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(parent, child); + fixture.CommitRenderedRelation(new( + parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1)); + fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); + fixture.Objects.AddOrUpdate(new ClientObject + { + ObjectId = child.ServerGuid, + WielderId = parent.ServerGuid, + CurrentlyEquippedLocation = EquipMask.MeleeWeapon, + }); + + Assert.Throws(() => + fixture.Objects.MoveItemOptimistic( + child.ServerGuid, + newContainerId: 0x50000001u, + newSlot: 0)); + Assert.True(fixture.Objects.RollbackMove(child.ServerGuid)); + fixture.Controller.Tick(); + + Assert.Equal(1, attempts); + Assert.True(child.IsSpatiallyProjected); + Assert.Single(fixture.Controller.AttachedEntityIds); + } + + [Fact] + public void OrphanRollback_PendingFailureRetainsExactRetryOwner() + { + int attempts = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + attempts++; + if (attempts == 1) + { + return new( + ExactProjectionWithdrawalDisposition.Pending, + new InvalidOperationException("orphan component cleanup failed")); + } + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord orphan = fixture.Spawn(0x70000269u, generation: 1); + + TargetInvocationException error = Assert.Throws(() => + fixture.InvokeOrphanRemoval(orphan)); + Assert.IsType(error.InnerException); + + fixture.Controller.Tick(); + + Assert.Equal(2, attempts); + Assert.False(orphan.IsSpatiallyProjected); + } + } + + [Fact] + public void RecoveryContinuation_PostCommitFailureIsNotReplayed() + { + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + LiveEntityRecord record = fixture.Spawn(0x70000270u, generation: 1); + int continuations = 0; + Assert.Throws(() => + fixture.Controller.OnChildBecameUnparented( + 0x70000270u, + () => + { + continuations++; + Assert.True(fixture.Live.RebucketLiveEntity( + 0x70000270u, + 0x01010001u)); + throw new InvalidOperationException("observer failed after recovery"); + })); + + fixture.Controller.Tick(); + + Assert.Equal(1, continuations); + Assert.True(record.IsSpatiallyProjected); + } + } + + private static LiveEntityRecord ChildRecord() => new( + new WorldSession.EntitySpawn( + 0x70000100u, + new CreateObject.ServerPosition( + 0x01010001u, 0f, 0f, 0f, 1f, 0f, 0f, 0f), + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "attached fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: 1)); + + private sealed class ControllerFixture : IDisposable + { + private const uint Cell = 0x01010001u; + private readonly DeferredLiveEntityRuntimeComponentLifecycle _lifecycle = new(); + private readonly Setup _setup; + + internal ControllerFixture( + Func + withdraw) + { + Spatial.AddLandblock(new LoadedLandblock( + (Cell & 0xFFFF0000u) | 0xFFFFu, + new LandBlock(), + Array.Empty())); + Live = new LiveEntityRuntime( + Spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + _lifecycle); + _setup = new Setup + { + HoldingLocations = + { + [(ParentLocation)0] = new LocationType + { + PartId = -1, + Frame = new Frame { Orientation = Quaternion.Identity }, + }, + }, + }; + IDatReaderWriter dat = DispatchProxy.Create(); + ((NullDatProxy)(object)dat).Setup = _setup; + Controller = new EquippedChildRenderController( + dat, + new object(), + Objects, + Live, + Poses, + update => Live.TryApplyParent(update, out _), + withdraw); + _lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle( + Controller.OnLogicalTeardown)); + } + + internal GpuWorldState Spatial { get; } = new(); + internal ClientObjectTable Objects { get; } = new(); + internal EntityEffectPoseRegistry Poses { get; } = new(); + internal LiveEntityRuntime Live { get; } + internal EquippedChildRenderController Controller { get; } + + internal LiveEntityRecord Spawn( + uint guid, + ushort generation, + LiveEntityProjectionKind kind = LiveEntityProjectionKind.World) + { + LiveEntityRecord record = RegisterOnly(guid, generation, hasPosition: true); + Materialize(record, kind); + return record; + } + + internal LiveEntityRecord RegisterOnly( + uint guid, + ushort generation, + bool hasPosition, + bool hasSetup = true) + { + WorldSession.EntitySpawn spawn = SpawnData(guid, generation); + if (!hasPosition) + spawn = spawn with { Position = null }; + if (!hasSetup) + spawn = spawn with { SetupTableId = null }; + return Live.RegisterLiveEntity(spawn).Record!; + } + + internal void Materialize( + LiveEntityRecord record, + LiveEntityProjectionKind kind = LiveEntityProjectionKind.World) + { + Live.MaterializeLiveEntity( + record.ServerGuid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = record.ServerGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + }, + kind); + record.HasPartArray = true; + } + + internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation) => new( + guid, + new CreateObject.ServerPosition( + Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f), + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "attached fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: generation); + + internal void InstallAttached( + LiveEntityRecord parent, + LiveEntityRecord child) + { + Type attachedType = typeof(EquippedChildRenderController).GetNestedType( + "AttachedChild", + BindingFlags.NonPublic)!; + object attached = Activator.CreateInstance( + attachedType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: + [ + parent, + child, + parent.ServerGuid, + child.ServerGuid, + (ParentLocation)0, + (Placement)0, + _setup, + _setup, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + 1f, + child.WorldEntity!, + ], + culture: null)!; + FieldInfo mapField = typeof(EquippedChildRenderController).GetField( + "_attachedByChild", + BindingFlags.Instance | BindingFlags.NonPublic)!; + var map = (IDictionary)mapField.GetValue(Controller)!; + map.Add(child.ServerGuid, attached); + } + + internal void CommitRenderedRelation(ParentAttachmentRelation relation) + { + Live.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(Live.ParentAttachments.CommitProjection(relation)); + Live.ParentAttachments.MarkProjected( + relation, + ParentProjectionCandidateKind.Recovery); + } + + internal void InvokeOrdinaryRemoval(uint guid) + { + MethodInfo method = typeof(EquippedChildRenderController).GetMethod( + "TearDownCurrentObjectProjections", + BindingFlags.Instance | BindingFlags.NonPublic)!; + method.Invoke(Controller, [guid]); + } + + internal void InvokeDetachedRemoval(uint guid) + { + MethodInfo method = typeof(EquippedChildRenderController).GetMethod( + "BeginDetachedRemoval", + BindingFlags.Instance | BindingFlags.NonPublic)!; + method.Invoke(Controller, [guid]); + } + + internal void InvokeOrphanRemoval(LiveEntityRecord record) + { + FieldInfo mapField = typeof(EquippedChildRenderController).GetField( + "_pendingOrphanRemovalByChild", + BindingFlags.Instance | BindingFlags.NonPublic)!; + object map = mapField.GetValue(Controller)!; + MethodInfo method = typeof(EquippedChildRenderController).GetMethod( + "BeginProjectionSubtreeWithdrawal", + BindingFlags.Instance | BindingFlags.NonPublic)!; + method.Invoke(Controller, [map, record, false, false]); + } + + public void Dispose() => Controller.Dispose(); + } + + private class NullDatProxy : DispatchProxy + { + internal Setup? Setup { get; set; } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == "Get" + && targetMethod.ReturnType == typeof(Setup)) + { + return Setup; + } + if (targetMethod?.ReturnType == typeof(void)) + return null; + if (targetMethod?.ReturnType.IsValueType == true) + return Activator.CreateInstance(targetMethod.ReturnType); + return null; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs index 4dcda425..0c690c23 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs @@ -1,5 +1,10 @@ using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Physics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using DatReaderWriter.DBObjs; @@ -8,6 +13,50 @@ namespace AcDream.App.Tests.Rendering; public sealed class LiveAppearanceAnimationTests { + [Fact] + public void Capture_UsesCanonicalRecordEntityAndAnimationOwner() + { + const uint guid = 0x70000002u; + var runtime = new AcDream.App.World.LiveEntityRuntime( + new AcDream.App.Streaming.GpuWorldState(), + new AcDream.App.World.DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + var record = runtime.RegisterLiveEntity(new AcDream.Core.Net.WorldSession.EntitySpawn( + guid, + Position: null, + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "appearance fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: 1)).Record!; + WorldEntity entity = Entity(0x70000002u, 0x01000001u); + var state = new LiveEntityAnimationState + { + Entity = entity, + Setup = new Setup(), + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = Array.Empty(), + PartAvailability = Array.Empty(), + }; + record.WorldEntity = entity; + record.AnimationRuntime = state; + + LiveEntityAppearanceUpdateState captured = Assert.IsType( + LiveEntityAppearanceBinding.Capture(runtime, guid)); + + Assert.Same(entity, captured.Entity); + Assert.Same(state, captured.Animation); + } + [Fact] public void RebindAppearance_PreservesEntitySequencerAndPlaybackState() { @@ -36,7 +85,7 @@ public sealed class LiveAppearanceAnimationTests ]; oldEntity.ApplyAppearance(dressedParts, paletteOverride: null, partOverrides: []); - GameWindow.RebindAnimatedEntityForAppearance( + LiveEntityAppearanceBinding.RebindAnimation( state, oldEntity, setup, @@ -57,6 +106,88 @@ public sealed class LiveAppearanceAnimationTests state.PartTemplate.Select(part => part.GfxObjId)); } + [Fact] + public void CollisionCommit_DelayedOldIncarnationCannotMutateGuidReplacement() + { + const uint guid = 0x70000010u; + const uint cell = 0x01010001u; + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var runtime = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + WorldSession.EntitySpawn first = Spawn(guid, cell, instance: 1); + runtime.RegisterLiveEntity(first); + WorldEntity oldEntity = runtime.MaterializeLiveEntity( + guid, + cell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000010u, + Position = Vector3.One, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = cell, + })!; + var registry = new ShadowObjectRegistry(); + registry.Register( + oldEntity.Id, + 0x01000010u, + oldEntity.Position, + oldEntity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: cell, + seedCellId: cell, + isStatic: false); + var builder = new LiveEntityCollisionBuilder( + _ => false, + _ => null, + new LiveEntityDefaultPoseResolver( + _ => null, + new NullLoader(), + dumpMotion: false)); + LiveEntityAppearanceCollisionUpdate update = + Assert.IsType( + LiveEntityAppearanceBinding.PrepareCollision( + runtime, + builder, + oldEntity, + new Setup(), + Array.Empty(), + first, + Vector3.Zero)); + + runtime.RegisterLiveEntity(Spawn(guid, cell, instance: 2)); + + Assert.False(LiveEntityAppearanceBinding.CommitCollision( + runtime, + registry, + update)); + Assert.Equal(1, registry.RetainedRegistrationCount); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell, ushort instance) => new( + guid, + new CreateObject.ServerPosition(cell, 1f, 1f, 1f, 1f, 0f, 0f, 0f), + 0x02000010u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "appearance collision fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: instance); + private static WorldEntity Entity(uint id, uint gfxObjId) => new() { Id = id, diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs index 7c1dd26d..334d7dc3 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -134,7 +134,7 @@ public sealed class LiveEntityAnimationPresenterTests replacementSetup.DefaultScale.Add(Vector3.One); fixture.Entity.MeshRefs = [new MeshRef(0x01000009u, Matrix4x4.Identity)]; fixture.Entity.SetIndexedPartPoses([Matrix4x4.Identity], [true]); - GameWindow.RebindAnimatedEntityForAppearance( + LiveEntityAppearanceBinding.RebindAnimation( fixture.State, fixture.Entity, replacementSetup, @@ -169,7 +169,7 @@ public sealed class LiveEntityAnimationPresenterTests var replacement = new Setup(); replacement.Parts.Add(0x01000099u); replacement.DefaultScale.Add(Vector3.One); - GameWindow.RebindAnimatedEntityForAppearance( + LiveEntityAppearanceBinding.RebindAnimation( fixture.State, fixture.Entity, replacement, diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs index 4b36fee5..61d258fc 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs @@ -453,6 +453,78 @@ public sealed class EntitySpawnAdapterLifetimeTests Assert.Equal(0, meshes.TotalReferenceCount); } + [Fact] + public void AppearanceChange_AfterPublicationRunsBeforeOldRetirement() + { + const uint guid = 0x7400C100u; + const ulong oldMesh = 0x01000A10u; + const ulong newMesh = 0x01000A11u; + var meshes = new CallbackMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(152u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + meshes.Operations.Clear(); + MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)]; + + Assert.True(adapter.OnAppearanceChanged( + entity, + replacement, + [], + () => + { + meshes.Operations.Add("publish"); + entity.ApplyAppearance(replacement, paletteOverride: null, []); + }, + () => + { + Assert.Same(replacement, entity.MeshRefs); + Assert.Equal(1, meshes.ReferenceCounts[oldMesh]); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + meshes.Operations.Add("after-publication"); + })); + + int afterPublication = meshes.Operations.IndexOf("after-publication"); + Assert.True(meshes.Operations.IndexOf("publish") < afterPublication); + Assert.True(meshes.Operations.IndexOf($"decrement:{oldMesh:X8}") > afterPublication); + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + + [Fact] + public void AppearanceChange_AfterPublicationFailureKeepsCommittedSetAndRetryRetiresOld() + { + const uint guid = 0x7400C200u; + const ulong oldMesh = 0x01000A20u; + const ulong newMesh = 0x01000A21u; + var meshes = new RefCountingMeshAdapter(); + var adapter = new EntitySpawnAdapter( + new RecordingTextureLifetime(), _ => MakeSequencer(), meshes); + WorldEntity entity = MakeEntity(153u, guid); + entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)]; + adapter.OnCreate(entity); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)]; + + Assert.Throws(() => adapter.OnAppearanceChanged( + entity, + replacement, + [], + () => entity.ApplyAppearance(replacement, paletteOverride: null, []), + () => throw new InvalidOperationException("injected dependent publication failure"))); + + Assert.Same(replacement, entity.MeshRefs); + Assert.Equal(1, meshes.ReferenceCounts[oldMesh]); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + Assert.True(adapter.SetPresentationResident(entity, resident: true)); + Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh)); + Assert.Equal(1, meshes.ReferenceCounts[newMesh]); + Assert.True(adapter.OnRemove(entity)); + Assert.Equal(0, meshes.TotalReferenceCount); + } + [Fact] public void AppearanceChange_WhileSuspended_OnlyReplacementSetIsAcquiredOnResume() { diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs new file mode 100644 index 00000000..7cf0d0c6 --- /dev/null +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -0,0 +1,51 @@ +using System.Collections; +using System.Reflection; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.World; + +namespace AcDream.App.Tests.World; + +public sealed class GameWindowLiveEntityCompositionTests +{ + private const BindingFlags PrivateImplementation = + BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic; + + [Theory] + [InlineData("RegisterLiveEntityCollision")] + [InlineData("MotionTableDefaultPose")] + [InlineData("LeaveWorldLiveEntityRuntimeComponents")] + [InlineData("WithdrawLiveEntityWorldProjection")] + [InlineData("RebindAnimatedEntityForAppearance")] + public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) + { + Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); + } + + [Fact] + public void GameWindow_DoesNotOwnAnAppearanceUpdateStateType() + { + Assert.Null(typeof(GameWindow).GetNestedType( + "AppearanceUpdateState", + BindingFlags.NonPublic)); + } + + [Theory] + [InlineData(typeof(LiveEntityCollisionBuilder))] + [InlineData(typeof(LiveEntityDefaultPoseResolver))] + [InlineData(typeof(LiveEntityProjectionWithdrawalController))] + [InlineData(typeof(LocalPlayerShadowState))] + [InlineData(typeof(LiveEntityAppearanceBinding))] + public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType) + { + foreach (FieldInfo field in helperType.GetFields( + BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic)) + { + Assert.False(typeof(IDictionary).IsAssignableFrom(field.FieldType), + $"{helperType.Name}.{field.Name} must resolve identity through LiveEntityRuntime."); + string typeName = field.FieldType.FullName ?? field.FieldType.Name; + Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal); + Assert.DoesNotContain("OpenGL", typeName, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs b/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs index 85f5dbe3..5bcaa934 100644 --- a/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs +++ b/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs @@ -208,7 +208,7 @@ public sealed class InboundPhysicsStateControllerTests } [Fact] - public void ParentEventWaitsForParent_ThenConsumesChildPositionOnce() + public void ParentEventWaitsForParent_ThenStagesTimestampUntilValidatedCommit() { var controller = new InboundPhysicsStateController(); controller.AcceptCreate(Spawn( @@ -219,7 +219,17 @@ public sealed class InboundPhysicsStateControllerTests Assert.False(controller.TryApplyParent(update, out _)); controller.AcceptCreate(Spawn( 0x70000004u, 9, 1, 1, Position(0x0101FFFFu, 15f), 0x408u)); - Assert.True(controller.TryApplyParent(update, out WorldSession.EntitySpawn attached)); + Assert.True(controller.TryApplyParent(update, out WorldSession.EntitySpawn staged)); + Assert.Null(staged.ParentGuid); + Assert.NotNull(staged.Position); + Assert.Equal((ushort)5, staged.PositionSequence); + Assert.True(controller.TryCommitParent( + update.ChildGuid, + update.ParentGuid, + update.ParentLocation, + update.PlacementId, + update.ChildPositionSequence, + out WorldSession.EntitySpawn attached)); Assert.Equal(0x70000004u, attached.ParentGuid); Assert.Null(attached.Position); Assert.False(controller.TryApplyParent(update, out _)); diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs new file mode 100644 index 00000000..20195c3d --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs @@ -0,0 +1,289 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.World; +using AcDream.Plugin.Abstractions; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityProjectionWithdrawalControllerTests +{ + private const uint Guid = 0x70000200u; + private const uint Cell = 0x01010001u; + + [Fact] + public void ComponentFailure_StopsBeforeCanonicalWithdrawal_AndRetryConverges() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + fixture.Poses.EffectPoseChanged += ThrowOnPoseChange; + + Assert.Throws(() => + fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + + Assert.True(record.IsSpatiallyProjected); + Assert.Empty(fixture.GameState.Entities); + Assert.Equal(0, fixture.Poses.Count); + Assert.DoesNotContain( + fixture.Physics.ShadowObjects.GetObjectsInCell(Cell), + entry => entry.EntityId == record.WorldEntity!.Id); + Assert.Equal(1, fixture.Physics.ShadowObjects.SuspendedRegistrationCount); + + fixture.Poses.EffectPoseChanged -= ThrowOnPoseChange; + Assert.True(fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + Assert.False(record.IsSpatiallyProjected); + + static void ThrowOnPoseChange(uint _) => + throw new InvalidOperationException("fixture pose observer failure"); + } + + [Fact] + public void RuntimeObserverFailure_CommitsCanonicalWithdrawalAfterComponentsRemoved() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + fixture.Live.ProjectionVisibilityChanged += ThrowOnHidden; + + Assert.ThrowsAny(() => + fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + + Assert.False(record.IsSpatiallyProjected); + Assert.False(record.IsSpatiallyVisible); + Assert.Empty(fixture.GameState.Entities); + Assert.Equal(0, fixture.Poses.Count); + + static void ThrowOnHidden(LiveEntityRecord _, bool visible) + { + if (!visible) + throw new InvalidOperationException("fixture runtime observer failure"); + } + } + + [Fact] + public void OrdinaryLeaveWorld_SuspendsRetainedShadow_AndReentryRestoresIt() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + uint localId = record.WorldEntity!.Id; + Assert.Contains( + fixture.Physics.ShadowObjects.GetObjectsInCell(Cell), + entry => entry.EntityId == localId); + + Assert.True(fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + Assert.DoesNotContain( + fixture.Physics.ShadowObjects.GetObjectsInCell(Cell), + entry => entry.EntityId == localId); + + Assert.True(fixture.Live.RebucketLiveEntity(Guid, Cell)); + Assert.Contains( + fixture.Physics.ShadowObjects.GetObjectsInCell(Cell), + entry => entry.EntityId == localId); + } + + [Fact] + public void ComponentCallbackReplacesGuid_DoesNotWithdrawReplacement() + { + var fixture = new Fixture(); + LiveEntityRecord old = fixture.Spawn(instance: 1); + LiveEntityRecord? replacement = null; + bool replacing = false; + fixture.LocalShadow.Set(Vector3.One, Quaternion.Identity, Cell); + fixture.Poses.EffectPoseChanged += OnPoseChanged; + + Assert.False(fixture.Controller.Withdraw(Guid, localPlayerGuid: Guid)); + + Assert.NotNull(replacement); + Assert.True(fixture.Live.TryGetRecord(Guid, out LiveEntityRecord current)); + Assert.Same(replacement, current); + Assert.True(current.IsSpatiallyProjected); + Assert.Equal(new Vector3(9f, 9f, 9f), current.WorldEntity!.Position); + Assert.NotNull(fixture.LocalShadow.Current); + Assert.NotSame(old, current); + + void OnPoseChanged(uint localId) + { + if (replacement is not null || replacing || localId != old.WorldEntity!.Id) + return; + replacing = true; + replacement = fixture.Spawn(instance: 2, position: new Vector3(9f, 9f, 9f)); + fixture.LocalShadow.Set(new Vector3(9f), Quaternion.Identity, Cell); + } + } + + [Fact] + public void ComponentCallbackReprojectsSameIncarnation_DoesNotWithdrawNewPosition() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + bool reentered = false; + fixture.Poses.EffectPoseChanged += OnPoseChanged; + + Assert.False(fixture.Controller.Withdraw(Guid, localPlayerGuid: 0u)); + + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.IsSpatiallyVisible); + Assert.Equal(new Vector3(8f, 7f, 6f), record.WorldEntity!.Position); + Assert.Contains(fixture.GameState.Entities, entity => entity.Id == record.WorldEntity.Id); + Assert.Equal(1, fixture.Poses.Count); + + void OnPoseChanged(uint localId) + { + if (reentered || localId != record.WorldEntity!.Id) + return; + reentered = true; + var position = new WorldSession.EntityPositionUpdate( + Guid, + new CreateObject.ServerPosition( + Cell, 8f, 7f, 6f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0); + Assert.True(fixture.Live.TryApplyPosition( + position, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + record.WorldEntity.SetPosition(new Vector3(8f, 7f, 6f)); + Assert.True(fixture.Live.RebucketLiveEntity( + Guid, + accepted.Position!.Value.LandblockId)); + var snapshot = new WorldEntitySnapshot( + record.WorldEntity.Id, + record.WorldEntity.SourceGfxObjOrSetupId, + record.WorldEntity.Position, + record.WorldEntity.Rotation); + fixture.GameState.Add(snapshot); + fixture.Events.UpsertCurrent(snapshot); + fixture.Poses.PublishMeshRefs(record.WorldEntity); + } + } + + [Fact] + public void LogicalLocalPlayerTeardownWithoutReplacement_ClearsShadowSnapshot() + { + var fixture = new Fixture(); + fixture.Spawn(instance: 1); + fixture.LocalShadow.Set(Vector3.One, Quaternion.Identity, Cell); + + Assert.True(fixture.Live.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, InstanceSequence: 1), + isLocalPlayer: false)); + + Assert.Null(fixture.LocalShadow.Current); + } + + private sealed class Fixture + { + internal Fixture() + { + Spatial.AddLandblock(new LoadedLandblock( + (Cell & 0xFFFF0000u) | 0xFFFFu, + new LandBlock(), + Array.Empty())); + var lifecycle = new DeferredLiveEntityRuntimeComponentLifecycle(); + Live = new LiveEntityRuntime( + Spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + lifecycle); + Projectiles = new ProjectileController( + Live, + Physics, + _ => null, + _ => { }, + () => (0, 0)); + Controller = new LiveEntityProjectionWithdrawalController( + Live, + Projectiles, + GameState, + Events, + Physics.ShadowObjects, + Poses, + LocalShadow); + Presentation = new LiveEntityPresentationController( + Live, + Physics.ShadowObjects, + (_, _, _) => false); + lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle( + record => Controller.LeaveWorld(record, Guid))); + } + + internal GpuWorldState Spatial { get; } = new(); + internal LiveEntityRuntime Live { get; } + internal PhysicsEngine Physics { get; } = new(); + internal ProjectileController Projectiles { get; } + internal WorldGameState GameState { get; } = new(); + internal WorldEvents Events { get; } = new(); + internal EntityEffectPoseRegistry Poses { get; } = new(); + internal LocalPlayerShadowState LocalShadow { get; } = new(); + internal LiveEntityProjectionWithdrawalController Controller { get; } + internal LiveEntityPresentationController Presentation { get; } + + internal LiveEntityRecord Spawn(ushort instance, Vector3? position = null) + { + WorldSession.EntitySpawn spawn = new( + Guid, + new CreateObject.ServerPosition(Cell, 1f, 2f, 3f, 1f, 0f, 0f, 0f), + 0x02000200u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "projection fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: instance); + LiveEntityRecord record = Live.RegisterLiveEntity(spawn).Record!; + WorldEntity entity = Live.MaterializeLiveEntity( + Guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000200u, + Position = position ?? new Vector3(1f, 2f, 3f), + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + })!; + var snapshot = new WorldEntitySnapshot( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); + GameState.Add(snapshot); + Events.UpsertCurrent(snapshot); + Poses.PublishMeshRefs(entity); + Physics.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: Cell, + seedCellId: Cell, + isStatic: false); + Assert.True(Presentation.OnLiveEntityReady(Guid)); + return record; + } + } +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index ebf3c29e..dfa9a0d4 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -659,7 +659,7 @@ public sealed class LiveEntityRuntimeTests } [Fact] - public void InitialChildCreate_PreservesParentEventQueuedForMissingChild() + public void InitialChildCreate_PreservesParentEventQueuedForRenderValidation() { const uint parentGuid = 0x70000020u; const uint childGuid = 0x70000021u; @@ -676,7 +676,9 @@ public sealed class LiveEntityRuntimeTests childGuid, out ParentAttachmentRelation relation)); Assert.Equal(parentGuid, relation.ParentGuid); Assert.True(runtime.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child)); - Assert.Null(child.Position); + Assert.NotNull(child.Position); + Assert.Null(child.ParentGuid); + Assert.Equal((ushort)5, child.PositionSequence); } [Fact] diff --git a/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs b/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs index 176808f4..5a1dd98b 100644 --- a/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs +++ b/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs @@ -27,9 +27,12 @@ public sealed class ParentAttachmentStateTests Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection)); Assert.Equal(parentGuid, projection.ParentGuid); Assert.True(inbound.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child)); + Assert.NotNull(child.Position); + Assert.Null(child.ParentGuid); + Assert.Equal((ushort)5, child.PositionSequence); + Assert.True(Commit(relations, inbound, projection, out child)); Assert.Null(child.Position); Assert.Equal(parentGuid, child.ParentGuid); - Assert.Equal((ushort)5, child.PositionSequence); } [Fact] @@ -50,9 +53,24 @@ public sealed class ParentAttachmentStateTests update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence)); Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection)); + Assert.Equal((ushort)5, projection.ChildPositionSequence); + Assert.Equal((uint)2, projection.PlacementId); + Assert.True(relations.CommitProjection(projection)); + relations.MarkProjected( + projection, + ParentProjectionCandidateKind.Recovery); + relations.Resolve( + childGuid, + _ => true, + guid => guid == parentGuid ? (ushort)9 : null, + update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence)); + Assert.True(relations.TryGetStagedProjection(childGuid, out projection)); Assert.Equal((ushort)6, projection.ChildPositionSequence); Assert.Equal((uint)3, projection.PlacementId); - relations.MarkProjected(childGuid); + Assert.True(relations.CommitProjection(projection)); + relations.MarkProjected( + projection, + ParentProjectionCandidateKind.Recovery); Assert.True(relations.RestoreLastAccepted(childGuid)); Assert.True(relations.TryGetProjection(childGuid, out projection)); Assert.Equal((ushort)6, projection.ChildPositionSequence); @@ -136,6 +154,7 @@ public sealed class ParentAttachmentStateTests Assert.Equal(parentGuid, projection.ParentGuid); Assert.Equal((ushort)10, projection.ParentInstanceSequence); Assert.Equal((ushort)5, projection.ChildPositionSequence); + Assert.True(Commit(relations, inbound, projection, out _)); Assert.True(inbound.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child)); Assert.Equal(parentGuid, child.ParentGuid); } @@ -257,6 +276,20 @@ public sealed class ParentAttachmentStateTests : null, update => inbound.TryApplyParent(update, out _)); + private static bool Commit( + ParentAttachmentState relations, + InboundPhysicsStateController inbound, + ParentAttachmentRelation relation, + out WorldSession.EntitySpawn accepted) => + inbound.TryCommitParent( + relation.ChildGuid, + relation.ParentGuid, + relation.ParentLocation, + relation.PlacementId, + relation.ChildPositionSequence, + out accepted) + && relations.CommitProjection(relation); + private static WorldSession.EntitySpawn Spawn( uint guid, ushort instance, diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 4673fb83..9c22ad6e 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -160,6 +160,110 @@ public class ShadowObjectRegistryTests Assert.Equal(0x44u, restored.State); } + [Fact] + public void ReplaceMultiPartPayload_VisibleOwnerKeepsExistingCellMembership() + { + const uint entityId = 25u; + var reg = new ShadowObjectRegistry(); + Vector3 position = new(12f, 12f, 50f); + reg.RegisterMultiPart( + entityId, + position, + Quaternion.Identity, + [BspShape(0x01000011u, radius: 0.5f)], + state: 0u, + flags: EntityCollisionFlags.None, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: false); + + reg.ReplaceMultiPartPayload( + entityId, + position, + Quaternion.Identity, + [BspShape(0x01000012u, radius: 40f)], + state: 0u, + flags: EntityCollisionFlags.None, + OffX, + OffY, + landblockId: 0u, + seedCellId: 0u, + isStatic: false); + + ShadowEntry replaced = Assert.Single(reg.GetObjectsInCell(LbId | 1u)); + Assert.Equal(0x01000012u, replaced.GfxObjId); + Assert.Empty(reg.GetObjectsInCell(LbId | 9u)); + } + + [Fact] + public void ReplaceMultiPartPayload_SuspendedShapedEmptyShaped_RemainsSuspended() + { + const uint entityId = 26u; + var reg = new ShadowObjectRegistry(); + Vector3 position = new(12f, 12f, 50f); + reg.RegisterMultiPart( + entityId, + position, + Quaternion.Identity, + [BspShape(0x01000021u, radius: 0.5f)], + state: 0u, + flags: EntityCollisionFlags.None, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: false); + Assert.True(reg.Suspend(entityId)); + + reg.ReplaceMultiPartPayload( + entityId, position, Quaternion.Identity, [], 0u, + EntityCollisionFlags.None, OffX, OffY, LbId, LbId | 1u); + reg.ReplaceMultiPartPayload( + entityId, + position, + Quaternion.Identity, + [BspShape(0x01000022u, radius: 0.5f)], + 0u, + EntityCollisionFlags.None, + OffX, + OffY, + LbId, + LbId | 1u); + + Assert.Equal(1, reg.RetainedRegistrationCount); + Assert.Equal(1, reg.SuspendedRegistrationCount); + Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); + reg.UpdatePosition( + entityId, position, Quaternion.Identity, OffX, OffY, LbId, LbId | 1u); + Assert.Equal(0x01000022u, Assert.Single(reg.GetObjectsInCell(LbId | 1u)).GfxObjId); + } + + [Fact] + public void ReplaceMultiPartPayload_NewCelllessOwnerRegistersRetainedButSuspended() + { + const uint entityId = 27u; + var reg = new ShadowObjectRegistry(); + reg.ReplaceMultiPartPayload( + entityId, + new Vector3(12f, 12f, 50f), + Quaternion.Identity, + [BspShape(0x01000031u, radius: 0.5f)], + 0u, + EntityCollisionFlags.None, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: false, + suspendIfNew: true); + + Assert.Equal(1, reg.RetainedRegistrationCount); + Assert.Equal(1, reg.SuspendedRegistrationCount); + Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); + } + // ----------------------------------------------------------------------- // RemoveLandblock // ----------------------------------------------------------------------- @@ -652,6 +756,15 @@ public class ShadowObjectRegistryTests }; } + private static ShadowShape BspShape(uint gfxObjId, float radius) => new( + gfxObjId, + Vector3.Zero, + Quaternion.Identity, + Scale: 1f, + CollisionType: ShadowCollisionType.BSP, + Radius: radius, + CylHeight: 0f); + private static CellPhysics BuildShadowCellSetTests_MakeLeafCell(Matrix4x4 worldTransform) { Matrix4x4.Invert(worldTransform, out var inv); diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs index 21a5c88e..a1ede47c 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs @@ -103,6 +103,26 @@ public class ShadowShapeBuilderTests Assert.Equal(1, bspCount); } + [Fact] + public void FromSetup_EffectivePartIdentitiesControlPhysicsBspSelection() + { + const uint replacementWithBsp = 0x0100AA01u; + const uint replacementWithoutBsp = 0x0100AA02u; + var setup = new Setup + { + Parts = { 0x010044B5u, 0x010044B6u }, + }; + + var shapes = ShadowShapeBuilder.FromSetup( + setup, + entScale: 1f, + hasPhysicsBsp: id => id == replacementWithBsp, + effectivePartGfxObjIds: [replacementWithBsp, replacementWithoutBsp]); + + ShadowShape shape = Assert.Single(shapes); + Assert.Equal(replacementWithBsp, shape.GfxObjId); + } + [Fact] public void FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders() {