refactor(world): extract live projection mechanics
Move appearance rebinding, collision construction, default-pose resolution, local shadow ownership, and exact leave-world presentation into focused owners. Preserve retail parent ordering with staged validation, committed recovery, recursive attached-subtree withdrawal, and retryable exact teardown across parent, pickup, position, unwield, and pose-loss edges. Co-Authored-By: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
c87bac31a0
commit
69a2ca0c6d
26 changed files with 4172 additions and 430 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
231
src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
Normal file
231
src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed record LiveEntityCollisionRegistration(
|
||||
uint EntityId,
|
||||
uint SourceId,
|
||||
Vector3 EntityWorldPosition,
|
||||
Quaternion EntityWorldRotation,
|
||||
IReadOnlyList<ShadowShape> Shapes,
|
||||
uint State,
|
||||
EntityCollisionFlags Flags,
|
||||
float WorldOffsetX,
|
||||
float WorldOffsetY,
|
||||
uint LandblockId,
|
||||
uint SeedCellId);
|
||||
|
||||
/// <summary>
|
||||
/// Ports the live-object collision-shape policy used by
|
||||
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, every
|
||||
/// physics-BSP part, and the established Setup-radius fallback for the
|
||||
/// remaining ACE prop data.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityCollisionBuilder
|
||||
{
|
||||
private readonly Func<uint, bool> _hasPhysicsBsp;
|
||||
private readonly Func<uint, float?> _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<uint, bool> hasPhysicsBsp,
|
||||
Func<uint, float?> physicsBspRadius,
|
||||
LiveEntityDefaultPoseResolver defaultPose)
|
||||
{
|
||||
_hasPhysicsBsp = hasPhysicsBsp
|
||||
?? throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||||
_physicsBspRadius = physicsBspRadius
|
||||
?? throw new ArgumentNullException(nameof(physicsBspRadius));
|
||||
_defaultPose = defaultPose
|
||||
?? throw new ArgumentNullException(nameof(defaultPose));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves collision slot zero for every post-AnimPartChanged identity.
|
||||
/// This intentionally has no visual-LOD option or Setup-type input.
|
||||
/// </summary>
|
||||
public static uint[] ResolveEffectivePartIdentities(
|
||||
IReadOnlyList<uint> postAnimPartGfxObjIds,
|
||||
Func<uint, uint> 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<uint> 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<Frame>? defaultPose = _defaultPose.Resolve(
|
||||
spawn.MotionTableId ?? 0u,
|
||||
setup.Parts.Count);
|
||||
IReadOnlyList<ShadowShape> raw = ShadowShapeBuilder.FromSetup(
|
||||
setup,
|
||||
scale,
|
||||
_hasPhysicsBsp,
|
||||
partPoseOverride: defaultPose,
|
||||
effectivePartGfxObjIds: effectivePartGfxObjIds);
|
||||
var shapes = new List<ShadowShape>(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}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
59
src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs
Normal file
59
src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ShadowShapeBuilder"/>.
|
||||
/// The retained default-pose snapshot approximation is tracked as AP-84 in
|
||||
/// the retail divergence register until live per-frame collision poses land.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityDefaultPoseResolver
|
||||
{
|
||||
private readonly Func<uint, MotionTable?> _loadMotionTable;
|
||||
private readonly IAnimationLoader _animationLoader;
|
||||
private readonly bool _dumpMotion;
|
||||
|
||||
public LiveEntityDefaultPoseResolver(
|
||||
Func<uint, MotionTable?> loadMotionTable,
|
||||
IAnimationLoader animationLoader,
|
||||
bool dumpMotion)
|
||||
{
|
||||
_loadMotionTable = loadMotionTable
|
||||
?? throw new ArgumentNullException(nameof(loadMotionTable));
|
||||
_animationLoader = animationLoader
|
||||
?? throw new ArgumentNullException(nameof(animationLoader));
|
||||
_dumpMotion = dumpMotion;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Frame>? Resolve(uint motionTableId, int partCount)
|
||||
{
|
||||
if (motionTableId == 0u || partCount == 0)
|
||||
return null;
|
||||
|
||||
MotionTable? motionTable = _loadMotionTable(motionTableId);
|
||||
if (motionTable is null)
|
||||
return null;
|
||||
|
||||
IReadOnlyList<Frame>? 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;
|
||||
}
|
||||
}
|
||||
19
src/AcDream.App/Physics/LocalPlayerShadowState.cs
Normal file
19
src/AcDream.App/Physics/LocalPlayerShadowState.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>Session-scoped cache of the local player's last published shadow pose.</summary>
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
|||
/// </summary>
|
||||
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<LiveEntityAnimationState>(() => _liveEntities);
|
||||
_remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _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<DatReaderWriter.DBObjs.MotionTable>(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<DatReaderWriter.DBObjs.PhysicsScriptTable>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture the runtime identity that retail preserves across
|
||||
/// <c>DoObjDescChangesFromDefault</c>. Replacement visual data is hydrated
|
||||
/// first and then assigned to this same entity instance.
|
||||
/// </summary>
|
||||
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<LiveAnimationPartTemplate> partTemplate,
|
||||
IReadOnlyList<bool> partAvailability)
|
||||
{
|
||||
animation.Entity = entity;
|
||||
animation.Setup = setup;
|
||||
animation.Scale = scale;
|
||||
animation.PartTemplate = partTemplate;
|
||||
animation.PartAvailability = partAvailability;
|
||||
animation.InvalidatePresentationPoses();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
|
||||
/// the <see cref="ShadowObjectRegistry"/> 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
|
||||
/// <c>Deregister(entity.Id)</c> cleans it up without leaks.
|
||||
///
|
||||
/// <para>
|
||||
/// Geometry-priority order matches retail
|
||||
/// (<c>acclient_2013_pseudo_c.txt:276858-276987</c>): CylSpheres >
|
||||
/// Sphere fallback > Setup.Radius. Phantom Setups (no shape) are
|
||||
/// rejected — retail's <c>FindObjCollisions</c> falls through to
|
||||
/// OK_TS in that case.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Carries <see cref="EntityCollisionFlags"/> derived from the PWD
|
||||
/// bitfield (<c>acclient.h:6431-6463</c>) plus <c>IsCreature</c>
|
||||
/// derived from the inbound ItemType. Commit C consumes these in
|
||||
/// the PvP exemption block.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<AcDream.Core.Physics.ShadowShape>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #175: the motion table's default-state pose (the closed pose for
|
||||
/// doors) — the derivation lives in
|
||||
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose"/> (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.
|
||||
/// </summary>
|
||||
private IReadOnlyList<DatReaderWriter.Types.Frame>? MotionTableDefaultPose(
|
||||
uint motionTableId, int partCount)
|
||||
{
|
||||
if (motionTableId == 0u || partCount == 0 || _dats is null) return null;
|
||||
|
||||
var mt = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::leave_world</c> (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 <see cref="TearDownLiveEntityRuntimeComponents"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5: shared retail <c>unpack_movement</c> type-6..9 routing
|
||||
/// (<c>0x00524440</c> 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;
|
||||
|
|
|
|||
127
src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs
Normal file
127
src/AcDream.App/Rendering/LiveEntityAppearanceBinding.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Exact runtime identity retained while retail
|
||||
/// <c>SmartBox::UpdateVisualDesc</c> mutates only a live object's visual
|
||||
/// description.
|
||||
/// </summary>
|
||||
internal sealed record LiveEntityAppearanceUpdateState(
|
||||
WorldEntity Entity,
|
||||
LiveEntityAnimationState? Animation);
|
||||
|
||||
internal sealed record LiveEntityAppearanceCollisionUpdate(
|
||||
LiveEntityRecord Record,
|
||||
WorldEntity Entity,
|
||||
LiveEntityCollisionRegistration? Registration);
|
||||
|
||||
/// <summary>
|
||||
/// Focused appearance-lifetime operations shared by ObjDesc hydration and the
|
||||
/// final animation presenter. This type owns no identity index; every capture
|
||||
/// is resolved from <see cref="LiveEntityRuntime"/>.
|
||||
/// </summary>
|
||||
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<LiveAnimationPartTemplate> partTemplate,
|
||||
IReadOnlyList<bool> 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<uint> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail <c>CPhysicsObj::leave_world</c>'s App projection tail for a
|
||||
/// still-live exact record. Component projection is removed before canonical
|
||||
/// runtime withdrawal, matching the shipped failure/retry boundary.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
|
@ -931,6 +931,38 @@ public sealed class LiveEntityRuntime
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool WithdrawLiveEntityProjection(LiveEntityRecord expectedRecord)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
return WithdrawLiveEntityProjection(
|
||||
expectedRecord,
|
||||
expectedRecord.PositionAuthorityVersion,
|
||||
expectedRecord.ProjectionMutationVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal bool WithdrawLiveEntityProjection(
|
||||
LiveEntityRecord expectedRecord,
|
||||
ulong expectedPositionAuthorityVersion,
|
||||
ulong expectedProjectionMutationVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
return IsCurrentRecord(expectedRecord)
|
||||
&& expectedRecord.PositionAuthorityVersion == expectedPositionAuthorityVersion
|
||||
&& expectedRecord.ProjectionMutationVersion == expectedProjectionMutationVersion
|
||||
&& WithdrawLiveEntityProjection(expectedRecord.ServerGuid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits retail <c>CPhysicsObj::set_parent</c>'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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,20 @@ namespace AcDream.App.World;
|
|||
/// <summary>
|
||||
/// 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 <c>PartArray::add_child</c>
|
||||
/// validation.
|
||||
/// </summary>
|
||||
public sealed class ParentAttachmentState
|
||||
{
|
||||
private readonly Dictionary<uint, Queue<ParentAttachmentRelation>> _unresolvedByChild = new();
|
||||
private readonly Dictionary<uint, ParentAttachmentRelation> _projectionByChild = new();
|
||||
private readonly Dictionary<uint, ParentAttachmentRelation> _stagedByChild = new();
|
||||
private readonly Dictionary<uint, ParentAttachmentRelation> _recoveryByChild = new();
|
||||
private readonly Dictionary<uint, ParentAttachmentRelation> _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<uint, ushort?> resolveInstance,
|
||||
Func<ParentEvent.Parsed, bool> accept)
|
||||
{
|
||||
if (_stagedByChild.ContainsKey(childGuid))
|
||||
return;
|
||||
if (!_unresolvedByChild.TryGetValue(childGuid, out Queue<ParentAttachmentRelation>? 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<uint> 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<uint, ParentAttachmentRelation> 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<uint> ChildrenWaitingForParent(uint parentGuid)
|
||||
{
|
||||
var result = new HashSet<uint>();
|
||||
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
|
|||
/// </summary>
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,6 +216,92 @@ public sealed class ShadowObjectRegistry
|
|||
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces an existing live PartArray collision payload in its current
|
||||
/// shadow-cell membership. Retail <c>CPartArray::SetPart</c> 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.
|
||||
/// </summary>
|
||||
public void ReplaceMultiPartPayload(
|
||||
uint entityId,
|
||||
Vector3 entityWorldPos,
|
||||
Quaternion entityWorldRot,
|
||||
System.Collections.Generic.IReadOnlyList<ShadowShape> 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<uint>? cells))
|
||||
return;
|
||||
|
||||
foreach (uint cellId in cells)
|
||||
{
|
||||
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail flood-sphere rule (CylSphere overload, Ghidra 0x0052b9f0):
|
||||
/// when the object has cylinder shapes, each contributes one sphere at
|
||||
|
|
|
|||
|
|
@ -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).</param>
|
||||
/// <param name="effectivePartGfxObjIds">Current part identities after
|
||||
/// retail <c>AnimPartChanged</c> 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.</param>
|
||||
public static IReadOnlyList<ShadowShape> FromSetup(
|
||||
Setup setup,
|
||||
float entScale,
|
||||
Func<uint, bool> hasPhysicsBsp,
|
||||
IReadOnlyList<Frame>? partPoseOverride = null)
|
||||
IReadOnlyList<Frame>? partPoseOverride = null,
|
||||
IReadOnlyList<uint>? 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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
/// </summary>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<LiveEntityCollisionRegistration>(
|
||||
builder.Build(entity, setup, Array.Empty<uint>(), 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<LiveEntityCollisionRegistration>(
|
||||
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<LiveEntityCollisionRegistration>(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<LiveEntityCollisionRegistration>(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<LiveEntityCollisionRegistration>(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<LiveEntityCollisionRegistration>(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<LiveEntityCollisionRegistration>(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<LiveEntityCollisionRegistration>(builder.Build(
|
||||
entity, setup, [basePart], spawn, record, Vector3.Zero));
|
||||
var registry = new ShadowObjectRegistry();
|
||||
LiveEntityCollisionBuilder.Register(registry, initial);
|
||||
LiveEntityCollisionRegistration changed =
|
||||
Assert.IsType<LiveEntityCollisionRegistration>(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<uint>(),
|
||||
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<InvalidOperationException>(() => Builder().Build(
|
||||
entity,
|
||||
new Setup { Radius = 1f },
|
||||
Array.Empty<uint>(),
|
||||
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<MeshRef>(),
|
||||
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<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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<AcDream.Core.Net.Messages.CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<AcDream.Core.Net.Messages.CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<AcDream.Core.Net.Messages.CreateObject.SubPaletteSwap>(),
|
||||
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<LiveAnimationPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
};
|
||||
record.WorldEntity = entity;
|
||||
record.AnimationRuntime = state;
|
||||
|
||||
LiveEntityAppearanceUpdateState captured = Assert.IsType<LiveEntityAppearanceUpdateState>(
|
||||
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<WorldEntity>()));
|
||||
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<MeshRef>(),
|
||||
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<LiveEntityAppearanceCollisionUpdate>(
|
||||
LiveEntityAppearanceBinding.PrepareCollision(
|
||||
runtime,
|
||||
builder,
|
||||
oldEntity,
|
||||
new Setup(),
|
||||
Array.Empty<uint>(),
|
||||
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<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(() => 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 _));
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(() =>
|
||||
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<Exception>(() =>
|
||||
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<WorldEntity>()));
|
||||
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<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
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<MeshRef>(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue