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