diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 24a5d55c..781d88d8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) +## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -103,6 +103,7 @@ accepted-divergence entries (#96, #49, #50). | AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | | AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | +| AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | --- @@ -255,7 +256,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~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-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` | | TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` | -| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` | +| 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` | | TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order | | TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum | | TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 | diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs index 9c6c3191..310a3e2d 100644 --- a/src/AcDream.App/Net/LiveSessionResetManifest.cs +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -39,7 +39,6 @@ internal sealed class LiveSessionResetBindings public required Action AnimationHookFrames { get; init; } public required Action LivePresentation { get; init; } public required Action RemoteMovementDiagnostics { get; init; } - public required Action PhysicsHostIndex { get; init; } } /// @@ -92,7 +91,6 @@ internal static class LiveSessionResetManifest new("animation hook frames", bindings.AnimationHookFrames), new("live presentation", bindings.LivePresentation), new("remote movement diagnostics", bindings.RemoteMovementDiagnostics), - new("physics host index", bindings.PhysicsHostIndex), ]); } } diff --git a/src/AcDream.App/Physics/EntityPhysicsHost.cs b/src/AcDream.App/Physics/EntityPhysicsHost.cs new file mode 100644 index 00000000..3b8a89e9 --- /dev/null +++ b/src/AcDream.App/Physics/EntityPhysicsHost.cs @@ -0,0 +1,314 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.App.World; + +namespace AcDream.App.Physics; + +/// +/// R5-V2 — the App-side per entity: acdream's +/// stand-in for retail's CPhysicsObj as the movement managers see it. +/// One is built per entity (a remote RemoteMotion or the local player) +/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow +/// and stored on the exact LiveEntityRecord, so +/// can resolve OTHER entities' hosts — the +/// cross-entity delivery path the voyeur system +/// needs. +/// +/// Owns a (retail +/// CPhysicsObj::target_manager). Its set_target/clear_target/ +/// add_voyeur/remove_voyeur/receive_target_update seams +/// forward to it exactly as retail's CPhysicsObj does; the movement managers' +/// target seams are repointed here, replacing the AP-79 poll adapter. The +/// per-entity accessors (position/velocity/radius/contact/clocks) and the +/// fan-out are injected by GameWindow so this +/// class stays free of GameWindow's internals (code-structure rule #1). +/// +/// R5-V3: owns a too (retail +/// CPhysicsObj::position_manager — retail creates it lazily via +/// get_position_manager; acdream constructs it eagerly, which is +/// behaviorally identical because the empty facade no-ops until its first +/// StickTo/ConstrainTo). fans +/// deliveries to the injected MoveToManager fan FIRST, then the +/// PositionManager — retail CPhysicsObj::HandleUpdateTarget order +/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager +/// @0x00512c1a). +/// +public sealed class EntityPhysicsHost : IPhysicsObjHost +{ + private Func _getPosition; + private Func _getVelocity; + private Func _getRadius; + private Func _inContact; + private Func _minterpMaxSpeed; + private Func _curTime; + private Func _physicsTimerTime; + private Func _getObjectA; + private Action _handleUpdateTarget; + private Action _interruptCurrentMovement; + private readonly TargetManager _targetManager; + + public EntityPhysicsHost( + uint id, + Func getPosition, + Func getVelocity, + Func getRadius, + Func inContact, + Func minterpMaxSpeed, + Func curTime, + Func physicsTimerTime, + Func getObjectA, + Action handleUpdateTarget, + Action interruptCurrentMovement) + { + Id = id; + _getPosition = null!; + _getVelocity = null!; + _getRadius = null!; + _inContact = null!; + _minterpMaxSpeed = null!; + _curTime = null!; + _physicsTimerTime = null!; + _getObjectA = null!; + _handleUpdateTarget = null!; + _interruptCurrentMovement = null!; + Rebind( + getPosition, + getVelocity, + getRadius, + inContact, + minterpMaxSpeed, + curTime, + physicsTimerTime, + getObjectA, + handleUpdateTarget, + interruptCurrentMovement); + _targetManager = new TargetManager(this); + PositionManager = new PositionManager(this); + } + + /// + /// Rebinds the changing CPhysicsObj access seams without replacing this + /// object's retail-lifetime managers. A static/minimal live object can gain + /// a MovementManager later, and the local player controller can be rebuilt, + /// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager + /// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and + /// constraint state therefore survives the upgrade. + /// + private void Rebind( + Func getPosition, + Func getVelocity, + Func getRadius, + Func inContact, + Func minterpMaxSpeed, + Func curTime, + Func physicsTimerTime, + Func getObjectA, + Action handleUpdateTarget, + Action interruptCurrentMovement) + { + ArgumentNullException.ThrowIfNull(getPosition); + ArgumentNullException.ThrowIfNull(getVelocity); + ArgumentNullException.ThrowIfNull(getRadius); + ArgumentNullException.ThrowIfNull(inContact); + ArgumentNullException.ThrowIfNull(minterpMaxSpeed); + ArgumentNullException.ThrowIfNull(curTime); + ArgumentNullException.ThrowIfNull(physicsTimerTime); + ArgumentNullException.ThrowIfNull(getObjectA); + ArgumentNullException.ThrowIfNull(handleUpdateTarget); + ArgumentNullException.ThrowIfNull(interruptCurrentMovement); + + _getPosition = getPosition; + _getVelocity = getVelocity; + _getRadius = getRadius; + _inContact = inContact; + _minterpMaxSpeed = minterpMaxSpeed; + _curTime = curTime; + _physicsTimerTime = physicsTimerTime; + _getObjectA = getObjectA; + _handleUpdateTarget = handleUpdateTarget; + _interruptCurrentMovement = interruptCurrentMovement; + } + + /// + /// Applies a freshly composed delegate configuration while retaining this + /// host's exact identity and manager instances. + /// + private void RebindFrom(EntityPhysicsHost configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + if (configuration.Id != Id) + throw new ArgumentException( + "A physics-host configuration must match the existing host GUID.", + nameof(configuration)); + + Rebind( + configuration._getPosition, + configuration._getVelocity, + configuration._getRadius, + configuration._inContact, + configuration._minterpMaxSpeed, + configuration._curTime, + configuration._physicsTimerTime, + configuration._getObjectA, + configuration._handleUpdateTarget, + configuration._interruptCurrentMovement); + } + + /// + /// Publishes the first host for an exact live-object incarnation, or + /// enriches that incarnation's existing minimal host in place. This is the + /// shared remote/player composition seam; it deliberately never replaces + /// TargetManager or PositionManager ownership. + /// + internal static EntityPhysicsHost InstallOrRebind( + LiveEntityRuntime runtime, + LiveEntityRecord expectedRecord, + EntityPhysicsHost configuration) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(configuration); + if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current) + || !ReferenceEquals(current, expectedRecord)) + { + throw new InvalidOperationException( + $"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition."); + } + + if (current.PhysicsHost is null) + { + runtime.InstallPhysicsHost(current, configuration); + return configuration; + } + + if (current.PhysicsHost is not EntityPhysicsHost existing) + { + throw new InvalidOperationException( + $"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."); + } + + existing.RebindFrom(configuration); + return existing; + } + + /// + /// Builds the position-only CPhysicsObj facade used before an animated or + /// player movement owner exists. The delegates capture the exact live + /// record rather than the visible-world projection, so exit_world + /// notifications retain the object's last cell and frame after the picker + /// view has already withdrawn it. + /// + internal static EntityPhysicsHost CreateMinimal( + LiveEntityRecord record, + Func resolve, + Func now) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(resolve); + ArgumentNullException.ThrowIfNull(now); + return new EntityPhysicsHost( + record.ServerGuid, + getPosition: () => new Position( + record.FullCellId, + record.WorldEntity?.Position ?? Vector3.Zero, + record.WorldEntity?.Rotation ?? Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0f, + inContact: () => true, + minterpMaxSpeed: () => null, + curTime: now, + physicsTimerTime: now, + getObjectA: resolve, + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + } + + // ── IPhysicsObjHost accessors ────────────────────────────────────────── + public uint Id { get; } + public Position Position => _getPosition(); + public Vector3 Velocity => _getVelocity(); + public float Radius => _getRadius(); + public bool InContact => _inContact(); + public float? MinterpMaxSpeed => _minterpMaxSpeed(); + public double CurTime => _curTime(); + public double PhysicsTimerTime => _physicsTimerTime(); + + /// The owned voyeur manager (retail + /// CPhysicsObj::target_manager). + public TargetManager TargetManager => _targetManager; + + /// R5-V3 — the owned facade (retail + /// CPhysicsObj::position_manager): sticky follow + (unarmed) + /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick, + /// MotionInterpreter.UnstickFromObject, the per-tick + /// AdjustOffset/UseTime drivers. + public PositionManager PositionManager { get; } + + // ── IPhysicsObjHost fan-out / target-tracking seams ──────────────────── + public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id); + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => + _targetManager.GetRelationshipTarget(objectId); + + public void HandleUpdateTarget(TargetInfo info) + { + // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order: + // MovementManager (the injected MoveToManager fan) first, then + // PositionManager (@0x00512c1a — the R5-V3 sticky consumer). + _handleUpdateTarget(info); + PositionManager.HandleUpdateTarget(info); + } + public void InterruptCurrentMovement() => _interruptCurrentMovement(); + + public void SetTarget(uint contextId, uint objectId, float radius, double quantum) + => _targetManager.SetTarget(contextId, objectId, radius, quantum); + + public void ClearTarget() => _targetManager.ClearTarget(); + public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) => + _targetManager.ReceiveUpdate(info, sender); + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) + => _targetManager.AddVoyeur(watcher, radius, quantum); + public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) => + _targetManager.RemoveVoyeur(watcherId, expectedWatcher); + + // ── per-tick driver + lifecycle (called by GameWindow) ───────────────── + + /// Retail TargetManager::HandleTargetting — the per-tick + /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally + /// for every entity in UpdateObjectInternal, BEFORE the movement + /// managers' UseTime. + public void HandleTargetting() => _targetManager.HandleTargetting(); + + /// Retail CPhysicsObj::exit_world's + /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — tell every + /// watcher of this entity that it left the world (they drop the + /// stick/moveto). Called on despawn before the host is removed from the + /// registry. + public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); + + /// + /// A Hidden object remains logically alive but is removed from the cell + /// lookup/interaction surface. Retail's DetectionManager sends + /// LeftDetection from CObjCell::hide_object; that manager is + /// not ported yet, so the App bridges the same unavailability edge through + /// the existing target fan-out. The non-Ok update tears down watcher + /// MoveTo/Sticky consumers, and the watched-role table is cleared so an + /// UnHide re-subscription receives a fresh initial snapshot. The object's + /// own watcher role is deliberately preserved. + /// + public void NotifyHidden() => + _targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld); + + /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail + /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget + /// (drop this entity's OWN subscription) then + /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one + /// drops its stick/moveto — StickyManager::HandleUpdateTarget's + /// non-Ok teardown path). Called after a teleport placement. + public void NotifyTeleported() + { + _targetManager.ClearTarget(); + _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); + } +} diff --git a/src/AcDream.App/Physics/RemoteMotion.cs b/src/AcDream.App/Physics/RemoteMotion.cs index 7b35d0bd..e29ee21c 100644 --- a/src/AcDream.App/Physics/RemoteMotion.cs +++ b/src/AcDream.App/Physics/RemoteMotion.cs @@ -1,4 +1,3 @@ -using AcDream.App.Rendering; using AcDream.App.World; namespace AcDream.App.Physics; @@ -22,7 +21,8 @@ namespace AcDream.App.Physics; /// /// internal sealed class RemoteMotion : - AcDream.App.World.ILiveEntityRemotePlacementRuntime + AcDream.App.World.ILiveEntityRemotePlacementRuntime, + AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer { public AcDream.Core.Physics.PhysicsBody Body { get; } AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; @@ -82,8 +82,13 @@ internal sealed class RemoteMotion : // voyeur system (retail CPhysicsObj::target_manager). Replaces the // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/ // clear_target/quantum seams route here, HandleTargetting ticks per - // frame, and OTHER entities resolve this one via GameWindow._physicsHosts. - public EntityPhysicsHost? Host; + // frame, and OTHER entities resolve this one through LiveEntityRuntime's + // exact-incarnation PhysicsHost slot. + private Func? _physicsHostReader; + private bool _fullPhysicsHostBound; + public EntityPhysicsHost? Host => _fullPhysicsHostBound + ? _physicsHostReader?.Invoke() as EntityPhysicsHost + : null; /// /// True while a server MoveToObject/MoveToPosition packet is the /// active locomotion source. Retail runs these through MoveToManager @@ -299,9 +304,42 @@ internal sealed class RemoteMotion : }); } + public void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell) + { + ArgumentNullException.ThrowIfNull(readPhysicsHost); + ArgumentNullException.ThrowIfNull(readCell); + ArgumentNullException.ThrowIfNull(writeCell); + if (_physicsHostReader is not null + || _canonicalCellReader is not null + || _canonicalCellWriter is not null) + { + throw new InvalidOperationException("The remote canonical runtime context is already bound."); + } + + // All validation precedes publication. Nothing below can throw, so a + // rejected context never leaves a half-bound reusable component. + _physicsHostReader = readPhysicsHost; + _canonicalCellReader = readCell; + _canonicalCellWriter = writeCell; + } + public void BindCanonicalCell(Func read, Action write) { - _canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read)); - _canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write)); + ArgumentNullException.ThrowIfNull(read); + ArgumentNullException.ThrowIfNull(write); + if (_canonicalCellReader is not null || _canonicalCellWriter is not null) + throw new InvalidOperationException("The remote canonical cell source is already bound."); + _canonicalCellReader = read; + _canonicalCellWriter = write; + } + + public void MarkFullPhysicsHostBound() + { + if (_physicsHostReader is null) + throw new InvalidOperationException("The canonical physics-host source is not bound."); + _fullPhysicsHostBound = true; } } diff --git a/src/AcDream.App/Rendering/EntityPhysicsHost.cs b/src/AcDream.App/Rendering/EntityPhysicsHost.cs deleted file mode 100644 index 3aa1d2e7..00000000 --- a/src/AcDream.App/Rendering/EntityPhysicsHost.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; - -namespace AcDream.App.Rendering; - -/// -/// R5-V2 — the App-side per entity: acdream's -/// stand-in for retail's CPhysicsObj as the movement managers see it. -/// One is built per entity (a remote RemoteMotion or the local player) -/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow -/// and registered in GameWindow._physicsHosts (guid → host), so -/// can resolve OTHER entities' hosts — the -/// cross-entity delivery path the voyeur system -/// needs. -/// -/// Owns a (retail -/// CPhysicsObj::target_manager). Its set_target/clear_target/ -/// add_voyeur/remove_voyeur/receive_target_update seams -/// forward to it exactly as retail's CPhysicsObj does; the movement managers' -/// target seams are repointed here, replacing the AP-79 poll adapter. The -/// per-entity accessors (position/velocity/radius/contact/clocks) and the -/// fan-out are injected by GameWindow so this -/// class stays free of GameWindow's internals (code-structure rule #1). -/// -/// R5-V3: owns a too (retail -/// CPhysicsObj::position_manager — retail creates it lazily via -/// get_position_manager; acdream constructs it eagerly, which is -/// behaviorally identical because the empty facade no-ops until its first -/// StickTo/ConstrainTo). fans -/// deliveries to the injected MoveToManager fan FIRST, then the -/// PositionManager — retail CPhysicsObj::HandleUpdateTarget order -/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager -/// @0x00512c1a). -/// -public sealed class EntityPhysicsHost : IPhysicsObjHost -{ - private readonly Func _getPosition; - private readonly Func _getVelocity; - private readonly Func _getRadius; - private readonly Func _inContact; - private readonly Func _minterpMaxSpeed; - private readonly Func _curTime; - private readonly Func _physicsTimerTime; - private readonly Func _getObjectA; - private readonly Action _handleUpdateTarget; - private readonly Action _interruptCurrentMovement; - private readonly TargetManager _targetManager; - - public EntityPhysicsHost( - uint id, - Func getPosition, - Func getVelocity, - Func getRadius, - Func inContact, - Func minterpMaxSpeed, - Func curTime, - Func physicsTimerTime, - Func getObjectA, - Action handleUpdateTarget, - Action interruptCurrentMovement) - { - Id = id; - _getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition)); - _getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity)); - _getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius)); - _inContact = inContact ?? throw new ArgumentNullException(nameof(inContact)); - _minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed)); - _curTime = curTime ?? throw new ArgumentNullException(nameof(curTime)); - _physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime)); - _getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA)); - _handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget)); - _interruptCurrentMovement = interruptCurrentMovement - ?? throw new ArgumentNullException(nameof(interruptCurrentMovement)); - _targetManager = new TargetManager(this); - PositionManager = new PositionManager(this); - } - - // ── IPhysicsObjHost accessors ────────────────────────────────────────── - public uint Id { get; } - public Position Position => _getPosition(); - public Vector3 Velocity => _getVelocity(); - public float Radius => _getRadius(); - public bool InContact => _inContact(); - public float? MinterpMaxSpeed => _minterpMaxSpeed(); - public double CurTime => _curTime(); - public double PhysicsTimerTime => _physicsTimerTime(); - - /// The owned voyeur manager (retail - /// CPhysicsObj::target_manager). - public TargetManager TargetManager => _targetManager; - - /// R5-V3 — the owned facade (retail - /// CPhysicsObj::position_manager): sticky follow + (unarmed) - /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick, - /// MotionInterpreter.UnstickFromObject, the per-tick - /// AdjustOffset/UseTime drivers. - public PositionManager PositionManager { get; } - - // ── IPhysicsObjHost fan-out / target-tracking seams ──────────────────── - public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id); - - public void HandleUpdateTarget(TargetInfo info) - { - // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order: - // MovementManager (the injected MoveToManager fan) first, then - // PositionManager (@0x00512c1a — the R5-V3 sticky consumer). - _handleUpdateTarget(info); - PositionManager.HandleUpdateTarget(info); - } - public void InterruptCurrentMovement() => _interruptCurrentMovement(); - - public void SetTarget(uint contextId, uint objectId, float radius, double quantum) - => _targetManager.SetTarget(contextId, objectId, radius, quantum); - - public void ClearTarget() => _targetManager.ClearTarget(); - public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info); - public void AddVoyeur(uint watcherId, float radius, double quantum) - => _targetManager.AddVoyeur(watcherId, radius, quantum); - public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId); - - // ── per-tick driver + lifecycle (called by GameWindow) ───────────────── - - /// Retail TargetManager::HandleTargetting — the per-tick - /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally - /// for every entity in UpdateObjectInternal, BEFORE the movement - /// managers' UseTime. - public void HandleTargetting() => _targetManager.HandleTargetting(); - - /// Retail CPhysicsObj::exit_world's - /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — tell every - /// watcher of this entity that it left the world (they drop the - /// stick/moveto). Called on despawn before the host is removed from the - /// registry. - public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); - - /// - /// A Hidden object remains logically alive but is removed from the cell - /// lookup/interaction surface. Retail's DetectionManager sends - /// LeftDetection from CObjCell::hide_object; that manager is - /// not ported yet, so the App bridges the same unavailability edge through - /// the existing target fan-out. The non-Ok update tears down watcher - /// MoveTo/Sticky consumers, and the watched-role table is cleared so an - /// UnHide re-subscription receives a fresh initial snapshot. The object's - /// own watcher role is deliberately preserved. - /// - public void NotifyHidden() => - _targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld); - - /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail - /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget - /// (drop this entity's OWN subscription) then - /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one - /// drops its stick/moveto — StickyManager::HandleUpdateTarget's - /// non-Ok teardown path). Called after a teleport placement. - public void NotifyTeleported() - { - _targetManager.ClearTarget(); - _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); - } -} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index dcd6ba55..dada8342 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -777,7 +777,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private IReadOnlyDictionary LastSpawns => _liveEntities?.Snapshots ?? EmptyLiveSpawnMap; // R5-V2: the local player's CPhysicsObj stand-in — owns the player's - // TargetManager voyeur system, registered in _physicsHosts so remote + // TargetManager voyeur system, stored on the exact live record so remote // entities chasing the player resolve it. Replaces the AP-79 // _playerMoveToTarget* poll fields. private EntityPhysicsHost? _playerHost; @@ -787,7 +787,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // giving the TargetManager voyeur round-trip its cross-entity delivery // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow // (player); pruned only by logical LiveEntityRuntime teardown. - private readonly Dictionary _physicsHosts = new(); private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; // ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184 @@ -2644,7 +2643,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AnimationHookFrames = () => _animationHookFrames?.Clear(), LivePresentation = () => _liveEntityPresentation?.Clear(), RemoteMovementDiagnostics = _remoteLastMove.Clear, - PhysicsHostIndex = _physicsHosts.Clear, }); private void ResetSessionMouseCapture() @@ -4714,7 +4712,12 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // live object has no PartArray sink, so build the manager once anyway. if (rm.Host is not null) return rm.Sink; - + if (_liveEntities is not { } liveEntities + || !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord) + || !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm)) + { + return rm.Sink; + } // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness // wiring (the conformance-tested reference). Positions are WORLD // space on both sides (getPosition + the MovementStruct positions @@ -4799,12 +4802,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // HandleUpdateTarget fans deliveries to this entity's MoveToManager // and (R5-V3, inside the host) its PositionManager — retail's // CPhysicsObj::HandleUpdateTarget order. - host = new EntityPhysicsHost( + var configuredHost = new EntityPhysicsHost( serverGuid, getPosition: () => new AcDream.Core.Physics.Position( - rmT.CellId, - _entitiesByServerGuid.TryGetValue(serverGuid, out var selfEnt) - ? selfEnt.Position : mtBody.Position, + hostRecord.FullCellId, + hostRecord.WorldEntity?.Position ?? mtBody.Position, mtBody.Orientation), getVelocity: () => mtBody.Velocity, getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, @@ -4820,8 +4822,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info), interruptCurrentMovement: () => rmT.Movement.CancelMoveTo( AcDream.Core.Physics.WeenieError.ActionCancelled)); - rm.Host = host; - _physicsHosts[serverGuid] = host; + host = EntityPhysicsHost.InstallOrRebind( + liveEntities, + hostRecord, + configuredHost); + rm.MarkFullPhysicsHostBound(); // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the // MoveToManager via the factory above (host now exists for the @@ -4851,42 +4856,38 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id) { - // CObjCell::hide_object removes Hidden objects from the lookup surface - // used to establish new target/voyeur relationships. Existing - // subscriptions are cleared by LiveEntityPresentationController. - if (!_visibleEntitiesByServerGuid.ContainsKey(id)) + if (_liveEntities is not { } liveEntities) + return null; + + bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord); + if (!isActive) + return null; + + // TS-49: CObjCell::hide_object removes Hidden objects from the + // relationship surface until DetectionManager is ported. Pending, + // attached, and otherwise non-render-visible active objects remain in + // CObjectMaint's object table and must still resolve here. + if (liveEntities.IsHidden(id)) { if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) { Console.WriteLine( $"[autowalk-host-miss] object=0x{id:X8} " + $"materialized={_entitiesByServerGuid.ContainsKey(id)} " - + $"registered={_physicsHosts.ContainsKey(id)} " - + $"hidden={_liveEntities?.IsHidden(id) == true}"); + + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} " + + $"hidden={liveEntities.IsHidden(id)}"); } return null; } - if (_physicsHosts.TryGetValue(id, out var existing)) + if (liveEntities.TryGetPhysicsHost(id, out var existing)) return existing; double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - var minimal = new EntityPhysicsHost( - id, - getPosition: () => new AcDream.Core.Physics.Position( - 0u, - _visibleEntitiesByServerGuid.TryGetValue(id, out var e) - ? e.Position : System.Numerics.Vector3.Zero, - System.Numerics.Quaternion.Identity), - getVelocity: () => System.Numerics.Vector3.Zero, // static target - getRadius: () => 0f, - inContact: () => true, - minterpMaxSpeed: () => null, - curTime: NowSeconds, - physicsTimerTime: NowSeconds, - getObjectA: ResolvePhysicsHost, - handleUpdateTarget: _ => { }, // not a watcher - interruptCurrentMovement: () => { }); - _physicsHosts[id] = minimal; + var minimal = EntityPhysicsHost.CreateMinimal( + activeRecord, + ResolvePhysicsHost, + NowSeconds); + liveEntities.InstallPhysicsHost(activeRecord, minimal); return minimal; } @@ -4973,7 +4974,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext AcDream.Core.Selection.SelectionChangeSource.System, AcDream.Core.Selection.SelectionChangeReason.Cleared); - if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost) + if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true && hiddenHost is EntityPhysicsHost hiddenEntityHost) hiddenEntityHost.NotifyHidden(); } @@ -5038,15 +5039,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // moveto/stick. This is the ONLY clean-up for a watcher whose target // already sent an Ok (once status != Undefined the 10 s staleness // timeout never fires), so it must run before the host is pruned. - EntityPhysicsHost? ephGone = rmGone?.Host; - if (ephGone is null) - { - incarnation.RunIfNoReplacement(() => - { - if (_physicsHosts.TryGetValue(serverGuid, out var hostGone)) - ephGone = hostGone as EntityPhysicsHost; - }); - } + EntityPhysicsHost? ephGone = record.PhysicsHost as EntityPhysicsHost; if (ephGone is not null) { // R5-V3 (#171): retail exit_world (0x00514e60) order — the @@ -5061,8 +5054,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext cleanups.Add(ephGone.PositionManager.UnStick); cleanups.Add(ephGone.ClearTarget); cleanups.Add(ephGone.NotifyExitWorld); - EntityPhysicsHost capturedHost = ephGone; - cleanups.Add(() => incarnation.RemoveCaptured(_physicsHosts, capturedHost)); } cleanups.Add(() => _animatedEntities.Remove(existingEntity.Id)); cleanups.Add(() => _classificationCache.InvalidateEntity(existingEntity.Id)); @@ -5259,7 +5250,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext bool targetVisible = turnPath.TargetGuid is { } visibleGuid && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid); bool targetHost = turnPath.TargetGuid is { } hostGuid - && _physicsHosts.ContainsKey(hostGuid); + && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true; var moveTo = movement.MoveTo; Console.WriteLine( $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} " @@ -10922,9 +10913,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Func isCurrent) { _remoteDeadReckon.TryGetValue(serverGuid, out RemoteMotion? remote); - EntityPhysicsHost? host = _physicsHosts.TryGetValue(serverGuid, out var registered) - ? registered as EntityPhysicsHost - : remote?.Host; + EntityPhysicsHost? host = + _liveEntities?.TryGetPhysicsHost(serverGuid, out var registered) == true + ? registered as EntityPhysicsHost + : null; return AcDream.App.Physics.RemoteTeleportHook.Execute( new AcDream.App.Physics.RemoteTeleportHookActions( CancelMoveTo: error => remote?.Movement.CancelMoveTo(error), @@ -13139,18 +13131,19 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return false; } - LiveEntityRecord? playerRecord = null; - if (_liveEntities?.TryGetRecord( + if (_liveEntities is not { } playerLiveEntities + || !playerLiveEntities.TryGetRecord( _playerServerGuid, - out LiveEntityRecord foundPlayerRecord) == true) + out LiveEntityRecord playerRecord)) { - playerRecord = foundPlayerRecord; + Console.WriteLine( + $"live: {loggingTag} — player record 0x{_playerServerGuid:X8} not found yet"); + return false; } _playerController = new AcDream.App.Input.PlayerMovementController( _physicsEngine, - playerRecord?.ObjectClock); - if (playerRecord is not null) - _playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState); + playerRecord.ObjectClock); + _playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState); // R4-V5: the local player's verbatim MoveToManager — same seam // wiring shape as EnsureRemoteMotionBindings, with three @@ -13232,15 +13225,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is // the player's WORLD position (WorldEntity.Position — what the AP-79 // poll used), so an NPC watching the player receives the identical - // position. Registered in _physicsHosts so those NPCs' GetObjectA + // position. Stored on the exact live record so those NPCs' GetObjectA // resolves the player; HandleTargetting is ticked in the player // pre-Update block. - playerHost = new EntityPhysicsHost( + var exactPlayerMovement = pcMoveTo.Movement; + var configuredPlayerHost = new EntityPhysicsHost( _playerServerGuid, getPosition: () => new AcDream.Core.Physics.Position( - pcMoveTo.CellId, - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pSelf) - ? pSelf.Position : pcMoveTo.Position, + playerRecord.FullCellId, + playerRecord.WorldEntity?.Position ?? pcMoveTo.Position, pcMoveTo.BodyOrientation), getVelocity: () => pcMoveTo.BodyVelocity, getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, @@ -13263,12 +13256,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext + $"{info.TargetPosition.Frame.Origin.Y:F2}," + $"{info.TargetPosition.Frame.Origin.Z:F2})"); } - _playerController?.Movement.HandleUpdateTarget(info); + exactPlayerMovement.HandleUpdateTarget(info); }, - interruptCurrentMovement: () => _playerController?.Movement.CancelMoveTo( + interruptCurrentMovement: () => exactPlayerMovement.CancelMoveTo( AcDream.Core.Physics.WeenieError.ActionCancelled)); + playerHost = EntityPhysicsHost.InstallOrRebind( + playerLiveEntities, + playerRecord, + configuredPlayerHost); _playerHost = playerHost; - _physicsHosts[_playerServerGuid] = playerHost; // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the // player MoveToManager via the factory above (playerHost now exists diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 7b064a2a..c82d8655 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -24,6 +24,29 @@ public interface ILiveEntityRemoteMotionRuntime PhysicsBody Body { get; } } +/// +/// Optional seam for a remote-motion component that reads the exact +/// incarnation's canonical movement-manager host from +/// . +/// +public interface ILiveEntityPhysicsHostConsumer +{ + void BindPhysicsHost(Func read); +} + +/// +/// Atomic composition seam for a component that consumes both canonical host +/// and cell identity. Implementations validate every delegate before +/// publishing any of them, so a failed bind leaves the component reusable. +/// +public interface ILiveEntityCanonicalRuntimeConsumer +{ + void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell); +} + /// /// Optional seam for a remote-motion component that consumes the canonical /// full-cell identity owned by . The runtime @@ -216,6 +239,8 @@ public sealed class LiveEntityRecord internal bool PhysicsBodyAcquisitionInProgress { get; set; } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } + internal bool RemoteMotionBindingInProgress { get; set; } + public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; } internal bool RequiresRemotePlacementRuntime { get; set; } public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; } @@ -1201,10 +1226,30 @@ public sealed class LiveEntityRuntime ArgumentNullException.ThrowIfNull(runtime); if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); + if (record.RemoteMotionBindingInProgress) + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} remote-motion binding is already in progress."); + PhysicsBody candidateBody = runtime.Body + ?? throw new InvalidOperationException("A remote-motion runtime returned no physics body."); + if (ReferenceEquals(record.RemoteMotionRuntime, runtime)) + { + if (!ReferenceEquals(record.PhysicsBody, candidateBody)) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} remote motion changed its canonical physics body."); + } + // Idempotent publication is important when motion and vector + // packets converge on the same runtime in one update turn. The + // component seams are incarnation-bound and must never be rebound. + candidateBody.State = record.FinalPhysicsState; + SynchronizePhysicsBodyActiveState(record); + _remoteMotionByGuid[serverGuid] = runtime; + RefreshSpatialRuntimeIndexes(record); + return; + } if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null) throw new InvalidOperationException( $"Live entity 0x{serverGuid:X8} cannot bind remote motion during physics-body acquisition."); - PhysicsBody candidateBody = runtime.Body; if (record.PhysicsBody is { } canonicalBody && !ReferenceEquals(canonicalBody, candidateBody)) { @@ -1217,38 +1262,134 @@ public sealed class LiveEntityRuntime throw new InvalidOperationException( $"Live entity 0x{serverGuid:X8} cannot discard its remote placement contract within one incarnation."); } - record.RequiresRemotePlacementRuntime |= - runtime is ILiveEntityRemotePlacementRuntime; - record.RemoteMotionRuntime = runtime; - record.PhysicsBody = candidateBody; - candidateBody.State = record.FinalPhysicsState; - SynchronizePhysicsBodyActiveState(record); - if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) + // Bind every possibly-throwing exact-incarnation seam before + // publishing any canonical record/index state. An already-bound + // component from an older GUID generation must fail without poisoning + // the replacement record. The callback is arbitrary App code, so the + // exact record, authority epoch, and expected owners are revalidated + // before commit just like resource/physics-body acquisition. + LiveEntityRecord incarnation = record; + ulong sessionVersion = _sessionLifetimeVersion; + ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid); + PhysicsBody? expectedBody = record.PhysicsBody; + ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime; + bool expectedPlacementContract = record.RequiresRemotePlacementRuntime; + bool expectedIndexPresent = _remoteMotionByGuid.TryGetValue( + serverGuid, + out ILiveEntityRemoteMotionRuntime? expectedIndexedRuntime); + Func readPhysicsHost = () => + _recordsByGuid.TryGetValue(serverGuid, out var current) + && ReferenceEquals(current, incarnation) + && ReferenceEquals(current.RemoteMotionRuntime, runtime) + ? current.PhysicsHost + : null; + // Reads remain bound to the exact CPhysicsObj incarnation through its + // exit_world notifications. Only writes require active ownership. + Func readCell = () => incarnation.FullCellId; + Action writeCell = cellId => { - // Capture the incarnation, not only its GUID. A callback retained - // by an old MovementManager must never mutate a replacement that - // later reuses the same server GUID. - LiveEntityRecord incarnation = record; - cellConsumer.BindCanonicalCell( - read: () => - _recordsByGuid.TryGetValue(serverGuid, out var current) - && ReferenceEquals(current, incarnation) - && ReferenceEquals(current.RemoteMotionRuntime, runtime) - ? current.FullCellId - : 0u, - write: cellId => + if (cellId != 0 + && _recordsByGuid.TryGetValue(serverGuid, out var current) + && ReferenceEquals(current, incarnation) + && ReferenceEquals(current.RemoteMotionRuntime, runtime)) + { + RebucketLiveEntity(serverGuid, cellId); + } + }; + + record.RemoteMotionBindingInProgress = true; + try + { + if (runtime is ILiveEntityCanonicalRuntimeConsumer canonicalConsumer) + { + canonicalConsumer.BindCanonicalRuntime( + readPhysicsHost, + readCell, + writeCell); + } + else + { + bool consumesHost = runtime is ILiveEntityPhysicsHostConsumer; + bool consumesCell = runtime is ILiveEntityCanonicalCellConsumer; + if (consumesHost && consumesCell) { - if (cellId != 0 - && _recordsByGuid.TryGetValue(serverGuid, out var current) - && ReferenceEquals(current, incarnation) - && ReferenceEquals(current.RemoteMotionRuntime, runtime)) - { - RebucketLiveEntity(serverGuid, cellId); - } - }); + throw new InvalidOperationException( + "A remote runtime that consumes both canonical host and cell identity must bind them atomically."); + } + if (runtime is ILiveEntityPhysicsHostConsumer hostConsumer) + hostConsumer.BindPhysicsHost(readPhysicsHost); + if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) + cellConsumer.BindCanonicalCell(readCell, writeCell); + } + + bool indexUnchanged = _remoteMotionByGuid.TryGetValue( + serverGuid, + out ILiveEntityRemoteMotionRuntime? currentIndexedRuntime) + == expectedIndexPresent + && (!expectedIndexPresent + || ReferenceEquals(currentIndexedRuntime, expectedIndexedRuntime)); + if (_sessionLifetimeVersion != sessionVersion + || CurrentLifetimeMutation(serverGuid) != lifetimeVersion + || !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + || !ReferenceEquals(current, incarnation) + || !ReferenceEquals(current.PhysicsBody, expectedBody) + || !ReferenceEquals(current.RemoteMotionRuntime, expectedRuntime) + || current.RequiresRemotePlacementRuntime != expectedPlacementContract + || !indexUnchanged) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} changed incarnation or component ownership during remote-motion binding."); + } + + record.RequiresRemotePlacementRuntime |= + runtime is ILiveEntityRemotePlacementRuntime; + record.RemoteMotionRuntime = runtime; + record.PhysicsBody = candidateBody; + candidateBody.State = record.FinalPhysicsState; + SynchronizePhysicsBodyActiveState(record); + _remoteMotionByGuid[serverGuid] = runtime; + RefreshSpatialRuntimeIndexes(record); } - _remoteMotionByGuid[serverGuid] = runtime; - RefreshSpatialRuntimeIndexes(record); + finally + { + record.RemoteMotionBindingInProgress = false; + } + } + + public void InstallPhysicsHost( + LiveEntityRecord expectedRecord, + AcDream.Core.Physics.Motion.IPhysicsObjHost host) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(host); + uint serverGuid = expectedRecord.ServerGuid; + if (host.Id != serverGuid) + throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host)); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || !ReferenceEquals(record, expectedRecord)) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} changed incarnation during physics-host installation."); + } + if (record.PhysicsHost is not null) + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} already owns its incarnation-stable physics host."); + record.PhysicsHost = host; + } + + public bool TryGetPhysicsHost( + uint serverGuid, + out AcDream.Core.Physics.Motion.IPhysicsObjHost host) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && record.PhysicsHost is { } existing) + { + host = existing; + return true; + } + + host = null!; + return false; } public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) => @@ -2374,6 +2515,7 @@ public sealed class LiveEntityRuntime } record.AnimationRuntime = null; record.RemoteMotionRuntime = null; + record.PhysicsHost = null; record.RequiresRemotePlacementRuntime = false; record.PhysicsBody = null; record.ProjectileRuntime = null; diff --git a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs index 85b4eb88..d5081b34 100644 --- a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs +++ b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs @@ -16,9 +16,9 @@ namespace AcDream.Core.Physics.Motion; /// RemoteMotion or the local player), wiring the accessors to the live /// and the / /// / it owns. -/// is backed by the App's live entity table -/// (_entitiesByServerGuid), giving the voyeur round-trip its -/// cross-entity delivery path. +/// is backed by the App's canonical +/// LiveEntityRuntime records, giving the voyeur round-trip its +/// cross-entity delivery path without a second GUID index. /// public interface IPhysicsObjHost { @@ -57,11 +57,21 @@ public interface IPhysicsObjHost double PhysicsTimerTime { get; } /// Retail CObjectMaint::GetObjectA(id) — resolve another - /// physics object by guid, or null if not currently known/visible. - /// The cross-entity seam for the voyeur round-trip and sticky live-target + /// physics object by guid from the object table. The App additionally + /// withholds Hidden objects from ordinary relationship creation through + /// its registered TS-49 DetectionManager adaptation. This is the + /// cross-entity seam for the voyeur round-trip and sticky live-target /// resolve. IPhysicsObjHost? GetObjectA(uint id); + /// + /// Returns the exact target incarnation captured by this host's current + /// TargetManager relationship. App teardown can overlap a newer + /// INSTANCE_TS with the same GUID, so StickyManager must not re-resolve the + /// relationship through the active GUID table. + /// + IPhysicsObjHost? GetRelationshipTarget(uint objectId); + /// Retail CPhysicsObj::HandleUpdateTarget — fans a /// to this host's /// (move-to steering) AND (sticky follow). @@ -86,15 +96,19 @@ public interface IPhysicsObjHost /// Retail CPhysicsObj::receive_target_update → /// . The inbound side a SENDER's /// SendVoyeurUpdate tail-calls on the watcher. - void ReceiveTargetUpdate(TargetInfo info); + void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender); /// Retail CPhysicsObj::add_voyeur(id, radius, quantum) → /// (lazily creating the - /// TargetManager). Called on the TARGET when a watcher subscribes. - void AddVoyeur(uint watcherId, float radius, double quantum); + /// TargetManager). Called on the TARGET when a watcher subscribes. The + /// exact watcher host preserves retail's unique object-table identity + /// while App teardown overlaps a newer same-GUID generation. + void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum); /// Retail CPhysicsObj::remove_voyeur(id) → /// . Called on the TARGET when a - /// watcher unsubscribes. - void RemoveVoyeur(uint watcherId); + /// watcher unsubscribes. The expected watcher identity prevents a retiring + /// same-GUID incarnation from removing a newer incarnation's subscription + /// while App teardown callbacks converge. + void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher); } diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs index 1646ce97..1ca0308b 100644 --- a/src/AcDream.Core/Physics/Motion/StickyManager.cs +++ b/src/AcDream.Core/Physics/Motion/StickyManager.cs @@ -210,7 +210,7 @@ public sealed class StickyManager return; var self = _host.Position; - var target = _host.GetObjectA(TargetId); + var target = _host.GetRelationshipTarget(TargetId); var targetPos = target != null ? target.Position : TargetPosition; // offset = local-frame, Z-flattened vector from self to target. diff --git a/src/AcDream.Core/Physics/Motion/TargetManager.cs b/src/AcDream.Core/Physics/Motion/TargetManager.cs index 7ca908a9..a26bb6ce 100644 --- a/src/AcDream.Core/Physics/Motion/TargetManager.cs +++ b/src/AcDream.Core/Physics/Motion/TargetManager.cs @@ -49,6 +49,7 @@ public sealed class TargetManager private readonly IPhysicsObjHost _host; private TargetInfo? _targetInfo; // retail target_info (watcher role) + private IPhysicsObjHost? _targetHost; // exact App incarnation token private Dictionary? _voyeurTable; // retail voyeur_table (watched role) private double _lastUpdateTime; // retail last_update_time (throttle base) @@ -67,6 +68,12 @@ public sealed class TargetManager /// quantum, 0 when tracking nothing. public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0; + /// The pointer-like target identity captured by SetTarget. + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => + _targetInfo is { } info && info.ObjectId == objectId + ? _targetHost + : null; + // ── watcher role ─────────────────────────────────────────────────────── /// @@ -101,7 +108,8 @@ public sealed class TargetManager LastUpdateTime: _host.CurTime); var target = _host.GetObjectA(objectId); - target?.AddVoyeur(_host.Id, radius, quantum); + _targetHost = target; + target?.AddVoyeur(_host, radius, quantum); } /// @@ -115,8 +123,9 @@ public sealed class TargetManager return; _targetInfo = ti with { Quantum = quantum }; - var target = _host.GetObjectA(ti.ObjectId); - target?.AddVoyeur(_host.Id, ti.Radius, quantum); + var target = _targetHost ?? _host.GetObjectA(ti.ObjectId); + _targetHost = target; + target?.AddVoyeur(_host, ti.Radius, quantum); } /// @@ -128,9 +137,10 @@ public sealed class TargetManager if (_targetInfo is not { } ti) return; - var target = _host.GetObjectA(ti.ObjectId); - target?.RemoveVoyeur(_host.Id); + var target = _targetHost ?? _host.GetObjectA(ti.ObjectId); + target?.RemoveVoyeur(_host.Id, _host); _targetInfo = null; + _targetHost = null; } /// @@ -141,10 +151,17 @@ public sealed class TargetManager /// time, recomputes the self→target interpolated heading (falls back to +Z /// when degenerate), fans the snapshot to the host, and drops the /// subscription on an ExitWorld status. + /// Receives the update only from the exact target incarnation that + /// established this relationship. The sender token stays outside the + /// retail TargetInfo payload. /// - public void ReceiveUpdate(TargetInfo update) + public void ReceiveUpdate(TargetInfo update, IPhysicsObjHost sender) { - if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId) + ArgumentNullException.ThrowIfNull(sender); + if (_targetInfo is not { } ti + || ti.ObjectId != update.ObjectId + || _targetHost is null + || !ReferenceEquals(_targetHost, sender)) return; // Copy radius/quantum/positions/velocity/status from the wire; keep our @@ -181,25 +198,53 @@ public sealed class TargetManager /// radius/quantum in place (no immediate send); otherwise creates the entry /// and pushes an immediate initial snapshot (Ok). /// - public void AddVoyeur(uint watcherId, float radius, double quantum) + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { + ArgumentNullException.ThrowIfNull(watcher); + uint watcherId = watcher.Id; _voyeurTable ??= new Dictionary(); if (_voyeurTable.TryGetValue(watcherId, out var existing)) { - existing.Radius = radius; - existing.Quantum = quantum; - return; + if (ReferenceEquals(existing.WatcherHost, watcher)) + { + existing.Radius = radius; + existing.Quantum = quantum; + return; + } + + // A later INSTANCE_TS reused the GUID. Replace the pointer-like + // subscription; the retiring watcher can no longer remove it via + // its exact identity token. + _voyeurTable.Remove(watcherId); } - var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum); + var voyeur = new TargettedVoyeurInfo( + watcherId, + radius, + quantum, + watcher); _voyeurTable[watcherId] = voyeur; SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok); } - /// Retail TargetManager::RemoveVoyeur (0x0051ad90). - public bool RemoveVoyeur(uint watcherId) - => _voyeurTable?.Remove(watcherId) ?? false; + /// + /// Retail TargetManager::RemoveVoyeur (0x0051ad90). Removes a + /// relationship only when it still belongs to the exact watcher + /// incarnation that established it in the App lifetime model. + /// + public bool RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) + { + ArgumentNullException.ThrowIfNull(expectedWatcher); + if (_voyeurTable is null + || !_voyeurTable.TryGetValue(watcherId, out var existing) + || !ReferenceEquals(existing.WatcherHost, expectedWatcher)) + { + return false; + } + + return _voyeurTable.Remove(watcherId); + } /// /// Retail TargetManager::HandleTargetting (0x0051aa90). THE per-tick @@ -280,8 +325,7 @@ public sealed class TargetManager Quantum: voyeur.Quantum, Velocity: _host.Velocity); - var voyeurObj = _host.GetObjectA(voyeur.ObjectId); - voyeurObj?.ReceiveTargetUpdate(info); + voyeur.WatcherHost.ReceiveTargetUpdate(info, _host); } /// diff --git a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs index 10631681..c4502114 100644 --- a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs +++ b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs @@ -11,6 +11,14 @@ namespace AcDream.Core.Physics.Motion; /// public sealed class TargettedVoyeurInfo { + /// + /// Exact watcher incarnation captured when this subscription is created. + /// Retail object IDs are process-lifetime object-table identities; the App + /// preserves that pointer-like identity explicitly while an old + /// INSTANCE_TS teardown may overlap a newer record with the same GUID. + /// + internal IPhysicsObjHost WatcherHost { get; } + /// +0x00 retail object_id — the subscriber's guid. public uint ObjectId { get; } @@ -27,10 +35,23 @@ public sealed class TargettedVoyeurInfo /// delivered to this subscriber (updated by SendVoyeurUpdate). public Position LastSentPosition { get; set; } - public TargettedVoyeurInfo(uint objectId, float radius, double quantum) + public TargettedVoyeurInfo( + uint objectId, + float radius, + double quantum, + IPhysicsObjHost watcherHost) { + ArgumentNullException.ThrowIfNull(watcherHost); + if (watcherHost.Id != objectId) + { + throw new ArgumentException( + "Watcher identity must match the voyeur object ID.", + nameof(watcherHost)); + } + ObjectId = objectId; Radius = radius; Quantum = quantum; + WatcherHost = watcherHost; } } diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index e55baf92..cce541e2 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -242,7 +242,6 @@ public sealed class LiveSessionResetPlanTests AnimationHookFrames = Stage("animation hook frames"), LivePresentation = Stage("live presentation"), RemoteMovementDiagnostics = Stage("remote movement diagnostics"), - PhysicsHostIndex = Stage("physics host index"), }); Assert.Equal(ExpectedManifestNames(), plan.StageNames); @@ -255,7 +254,6 @@ public sealed class LiveSessionResetPlanTests .Select(static error => error.StageName)); Assert.Equal(playerA, sessionIdentity); Assert.Equal(1, live.PendingTeardownCount); - Assert.False(dirty["physics host index"]); Assert.Contains(staticEntity, spatial.Entities); Assert.Equal(1, spatial.PersistentGuidCount); @@ -319,7 +317,6 @@ public sealed class LiveSessionResetPlanTests "animation hook frames", "live presentation", "remote movement diagnostics", - "physics host index", ]; private static WorldEntity Entity(uint id, uint guid) => new() diff --git a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs index 354830da..4db98835 100644 --- a/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs +++ b/tests/AcDream.App.Tests/Physics/PlayerMovementHiddenTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Input; +using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; using AcDream.Core.Physics; diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index 2bc55d79..f0fd81a4 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -1202,7 +1202,10 @@ public sealed class ProjectileControllerTests fixture.Live.SetRemoteMotionRuntime(Guid, replacement); remote.CellId = CellB; Assert.Equal(CellA, record.FullCellId); - Assert.Equal(0u, remote.CellId); + // A displaced component retains read-only access to its exact + // incarnation through exit_world; its guarded write cannot mutate the + // current canonical owner. + Assert.Equal(CellA, remote.CellId); replacement.CellId = CellB; Assert.Equal(CellB, record.FullCellId); } diff --git a/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs b/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs index cee150f4..0d78b7a5 100644 --- a/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EntityPhysicsHostTests.cs @@ -1,5 +1,5 @@ using System.Numerics; -using AcDream.App.Rendering; +using AcDream.App.Physics; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs new file mode 100644 index 00000000..c2a35f44 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs @@ -0,0 +1,745 @@ +using System.Numerics; +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.Physics.Motion; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityPhysicsHostOwnershipTests +{ + [Fact] + public void Host_IsOwnedByExactRecordAndStaleRemoteReaderCannotSeeReplacement() + { + const uint guid = 0x70000010u; + var runtime = Runtime(); + LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var firstMotion = new HostConsumerMotion(); + runtime.SetRemoteMotionRuntime(guid, firstMotion); + EntityPhysicsHost firstHost = Host(guid); + + runtime.InstallPhysicsHost(first, firstHost); + Assert.Same(firstHost, firstMotion.Host); + Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved)); + Assert.Same(firstHost, resolved); + + LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + var secondMotion = new HostConsumerMotion(); + runtime.SetRemoteMotionRuntime(guid, secondMotion); + EntityPhysicsHost replacement = Host(guid); + runtime.InstallPhysicsHost(second, replacement); + + Assert.Null(firstMotion.Host); + Assert.Same(replacement, secondMotion.Host); + Assert.Same(replacement, second.PhysicsHost); + } + + [Fact] + public void Install_RejectsWrongGuidDuplicateAndStaleRecord() + { + const uint guid = 0x70000011u; + var runtime = Runtime(); + LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + EntityPhysicsHost original = Host(guid); + runtime.InstallPhysicsHost(first, original); + + Assert.Throws(() => + runtime.InstallPhysicsHost(first, Host(guid + 1))); + Assert.Throws(() => + runtime.InstallPhysicsHost(first, Host(guid))); + + LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + Assert.Throws(() => + runtime.InstallPhysicsHost(first, Host(guid))); + Assert.Null(second.PhysicsHost); + } + + [Fact] + public void InstallOrRebind_PreservesHostManagersAndLiveTrackingState() + { + const uint watcherGuid = 0x70000020u; + const uint targetGuid = 0x70000021u; + var runtime = Runtime(); + LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; + LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + + EntityPhysicsHost watcher = Host( + watcherGuid, + resolve: Resolve, + position: new Vector3(1f, 0f, 0f)); + EntityPhysicsHost target = Host( + targetGuid, + resolve: Resolve, + position: new Vector3(4f, 0f, 0f)); + runtime.InstallPhysicsHost(watcherRecord, watcher); + runtime.InstallPhysicsHost(targetRecord, target); + + watcher.PositionManager.StickTo(targetGuid, 0.5f, 1f); + watcher.PositionManager.ConstrainTo( + new Position(0x01010001u, Vector3.Zero, Quaternion.Identity), + 1f, + 5f); + TargetManager targetManager = watcher.TargetManager; + PositionManager positionManager = watcher.PositionManager; + StickyManager sticky = Assert.IsType(positionManager.Sticky); + ConstraintManager constraint = Assert.IsType(positionManager.Constraint); + Assert.Equal(targetGuid, sticky.TargetId); + Assert.NotNull(watcher.TargetManager.TargetInfo); + Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid)); + + EntityPhysicsHost configured = Host( + watcherGuid, + resolve: Resolve, + position: new Vector3(9f, 0f, 0f)); + EntityPhysicsHost rebound = EntityPhysicsHost.InstallOrRebind( + runtime, + watcherRecord, + configured); + + Assert.Same(watcher, rebound); + Assert.Same(targetManager, rebound.TargetManager); + Assert.Same(positionManager, rebound.PositionManager); + Assert.Same(sticky, rebound.PositionManager.Sticky); + Assert.Same(constraint, rebound.PositionManager.Constraint); + Assert.Equal(targetGuid, rebound.PositionManager.GetStickyObjectId()); + Assert.Equal(9f, rebound.Position.Frame.Origin.X); + Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid)); + + EntityPhysicsHost secondConfiguration = Host( + watcherGuid, + resolve: Resolve, + position: new Vector3(12f, 0f, 0f)); + Assert.Same( + watcher, + EntityPhysicsHost.InstallOrRebind(runtime, watcherRecord, secondConfiguration)); + Assert.Same(targetManager, watcher.TargetManager); + Assert.Same(positionManager, watcher.PositionManager); + Assert.Equal(12f, watcher.Position.Frame.Origin.X); + } + + [Fact] + public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal() + { + const uint guid = 0x70000022u; + var runtime = Runtime(); + LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + int firstUpdates = 0; + int firstInterrupts = 0; + var firstHost = CallbackHost( + guid, + _ => firstUpdates++, + () => firstInterrupts++); + runtime.InstallPhysicsHost(first, firstHost); + + LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + int replacementUpdates = 0; + int replacementInterrupts = 0; + var replacementHost = CallbackHost( + guid, + _ => replacementUpdates++, + () => replacementInterrupts++); + runtime.InstallPhysicsHost(replacement, replacementHost); + + firstHost.HandleUpdateTarget(default); + firstHost.InterruptCurrentMovement(); + + Assert.Equal(1, firstUpdates); + Assert.Equal(1, firstInterrupts); + Assert.Equal(0, replacementUpdates); + Assert.Equal(0, replacementInterrupts); + } + + [Fact] + public void RemoteMotion_FullHostGateUsesCanonicalStableHost() + { + const uint guid = 0x70000030u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var motion = new RemoteMotion(new PhysicsBody()); + runtime.SetRemoteMotionRuntime(guid, motion); + EntityPhysicsHost minimal = Host(guid); + runtime.InstallPhysicsHost(record, minimal); + + Assert.Null(motion.Host); + motion.MarkFullPhysicsHostBound(); + Assert.Same(minimal, motion.Host); + + EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f)); + Assert.Same( + minimal, + EntityPhysicsHost.InstallOrRebind(runtime, record, configured)); + Assert.Same(minimal, motion.Host); + Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X); + + runtime.SetRemoteMotionRuntime(guid, motion); + Assert.Same(minimal, motion.Host); + } + + [Fact] + public void RemoteMotionBindingFailure_DoesNotPublishPartialCanonicalState() + { + const uint guid = 0x70000031u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var failing = new ThrowingCellConsumerMotion(); + + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, failing)); + Assert.Null(record.RemoteMotionRuntime); + Assert.Null(record.PhysicsBody); + Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); + + runtime.SetRemoteMotionRuntime(guid, failing); + Assert.Same(failing, record.RemoteMotionRuntime); + Assert.Same(failing.Body, record.PhysicsBody); + } + + [Fact] + public void AlreadyBoundRemoteFromOldGeneration_CannotPoisonReplacement() + { + const uint guid = 0x70000032u; + var runtime = Runtime(); + runtime.RegisterLiveEntity(Spawn(guid, 1)); + var stale = new RemoteMotion(new PhysicsBody()); + runtime.SetRemoteMotionRuntime(guid, stale); + + LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, stale)); + Assert.Null(replacement.RemoteMotionRuntime); + Assert.Null(replacement.PhysicsBody); + Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); + + var fresh = new RemoteMotion(new PhysicsBody()); + runtime.SetRemoteMotionRuntime(guid, fresh); + Assert.Same(fresh, replacement.RemoteMotionRuntime); + } + + [Fact] + public void SameRemoteRuntime_CannotChangeCanonicalBodyOnIdempotentBind() + { + const uint guid = 0x70000033u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var firstBody = new PhysicsBody(); + var secondBody = new PhysicsBody(); + var alternating = new AlternatingBodyMotion(firstBody, secondBody); + + runtime.SetRemoteMotionRuntime(guid, alternating); + Assert.Same(firstBody, record.PhysicsBody); + + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, alternating)); + Assert.Same(firstBody, record.PhysicsBody); + Assert.Same(alternating, record.RemoteMotionRuntime); + Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var indexed)); + Assert.Same(alternating, indexed); + } + + [Fact] + public void IdempotentRemoteBind_ReadsBodyOnceAndMutatesOnlyCanonicalBody() + { + const uint guid = 0x70000036u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var canonical = new PhysicsBody(); + var unrelated = new PhysicsBody(); + PhysicsStateFlags unrelatedInitialState = unrelated.State; + var scripted = new SequenceBodyMotion(canonical, canonical, unrelated); + + runtime.SetRemoteMotionRuntime(guid, scripted); + runtime.SetRemoteMotionRuntime(guid, scripted); + + Assert.Same(canonical, record.PhysicsBody); + Assert.Equal(record.FinalPhysicsState, canonical.State); + Assert.Equal(unrelatedInitialState, unrelated.State); + Assert.Equal(2, scripted.ReadCount); + } + + [Fact] + public void ReentrantBind_SameGuidReplacementPreventsOuterPublication() + { + const uint guid = 0x70000034u; + var runtime = Runtime(); + LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var motion = new CallbackCanonicalMotion(() => + runtime.RegisterLiveEntity(Spawn(guid, 2))); + + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, motion)); + + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + Assert.Equal((ushort)2, replacement.Generation); + Assert.Null(replacement.RemoteMotionRuntime); + Assert.Null(replacement.PhysicsBody); + Assert.Null(retired.RemoteMotionRuntime); + Assert.Null(retired.PhysicsBody); + Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); + } + + [Fact] + public void ReentrantBind_SessionClearPreventsOuterPublication() + { + const uint guid = 0x70000035u; + var runtime = Runtime(); + LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + var motion = new CallbackCanonicalMotion(runtime.Clear); + + Assert.Throws(() => + runtime.SetRemoteMotionRuntime(guid, motion)); + + Assert.Equal(0, runtime.Count); + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Null(retired.RemoteMotionRuntime); + Assert.Null(retired.PhysicsBody); + Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); + } + + [Fact] + public void Delete_TeardownHostsRemainPeerResolvableUntilExitWorldConverges() + { + const uint leftGuid = 0x70000040u; + const uint rightGuid = 0x70000041u; + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => CleanPhysicsHost(record)); + LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!; + LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + EntityPhysicsHost left = Host(leftGuid, Resolve, new Vector3(0f, 0f, 0f)); + EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f)); + runtime.InstallPhysicsHost(leftRecord, left); + runtime.InstallPhysicsHost(rightRecord, right); + left.PositionManager.StickTo(rightGuid, 0.5f, 1f); + right.PositionManager.StickTo(leftGuid, 0.5f, 1f); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(leftGuid, InstanceSequence: 1), + isLocalPlayer: false)); + + Assert.Null(leftRecord.PhysicsHost); + Assert.Equal(0u, right.PositionManager.GetStickyObjectId()); + Assert.Null(right.TargetManager.TargetInfo); + Assert.False(right.TargetManager.VoyeurTable?.ContainsKey(leftGuid) ?? false); + Assert.False(runtime.TryGetPhysicsHost(leftGuid, out _)); + } + + [Fact] + public void SessionClear_TeardownHostsResolveAcrossTombstones() + { + const uint leftGuid = 0x70000042u; + const uint rightGuid = 0x70000043u; + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => CleanPhysicsHost(record)); + LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!; + LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + EntityPhysicsHost left = Host(leftGuid, Resolve, Vector3.Zero); + EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f)); + runtime.InstallPhysicsHost(leftRecord, left); + runtime.InstallPhysicsHost(rightRecord, right); + left.PositionManager.StickTo(rightGuid, 0.5f, 1f); + right.PositionManager.StickTo(leftGuid, 0.5f, 1f); + + runtime.Clear(); + + Assert.Equal(0, runtime.PendingTeardownCount); + Assert.Null(leftRecord.PhysicsHost); + Assert.Null(rightRecord.PhysicsHost); + Assert.Equal(0u, left.PositionManager.GetStickyObjectId()); + Assert.Equal(0u, right.PositionManager.GetStickyObjectId()); + } + + [Fact] + public void FailedTeardown_RetryCannotMutateReplacementRelationship() + { + const uint guid = 0x70000044u; + const uint targetGuid = 0x70000045u; + bool failOnce = true; + EntityPhysicsHost? replacementHost = null; + LiveEntityRecord? replacementRecord = null; + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => + { + if (failOnce) + { + failOnce = false; + replacementRecord = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + replacementHost = Host(guid, Resolve); + runtime.InstallPhysicsHost(replacementRecord, replacementHost); + replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f); + throw new InvalidOperationException("fixture failure before old host cleanup"); + } + + CleanPhysicsHost(record); + }); + LiveEntityRecord oldRecord = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!; + IPhysicsObjHost? InitialResolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + EntityPhysicsHost oldHost = Host(guid, InitialResolve); + EntityPhysicsHost targetHost = Host(targetGuid, InitialResolve, new Vector3(2f, 0f, 0f)); + runtime.InstallPhysicsHost(oldRecord, oldHost); + runtime.InstallPhysicsHost(targetRecord, targetHost); + oldHost.PositionManager.StickTo(targetGuid, 0.5f, 1f); + + Assert.Throws(() => runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(guid, InstanceSequence: 1), + isLocalPlayer: false)); + Assert.Same(oldHost, oldRecord.PhysicsHost); + Assert.Same(replacementHost, replacementRecord!.PhysicsHost); + Assert.Equal(targetGuid, replacementHost!.PositionManager.GetStickyObjectId()); + Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid)); + + Assert.Equal(1, runtime.RetryPendingTeardowns()); + Assert.Null(oldRecord.PhysicsHost); + Assert.Same(replacementHost, replacementRecord.PhysicsHost); + Assert.Equal(targetGuid, replacementHost.PositionManager.GetStickyObjectId()); + Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid)); + } + + [Fact] + public void FailedTeardown_TombstoneCannotAcceptNewRelationshipsOutsideTeardown() + { + const uint retiredGuid = 0x70000046u; + const uint watcherGuid = 0x70000047u; + bool fail = true; + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => + { + CleanPhysicsHost(record); + if (fail) + { + fail = false; + throw new InvalidOperationException("fixture post-exit failure"); + } + }); + LiveEntityRecord retiredRecord = runtime.RegisterLiveEntity(Spawn(retiredGuid, 1)).Record!; + LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + EntityPhysicsHost retired = Host(retiredGuid, Resolve); + EntityPhysicsHost watcher = Host(watcherGuid, Resolve); + runtime.InstallPhysicsHost(retiredRecord, retired); + runtime.InstallPhysicsHost(watcherRecord, watcher); + + Assert.Throws(() => runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(retiredGuid, InstanceSequence: 1), + isLocalPlayer: false)); + watcher.SetTarget(0, retiredGuid, 0.5f, 0.5); + + Assert.Null(retired.TargetManager.VoyeurTable); + Assert.Equal(TargetStatus.Undefined, watcher.TargetManager.TargetInfo!.Value.Status); + Assert.False(runtime.TryGetPhysicsHost(retiredGuid, out _)); + Assert.Equal(1, runtime.RetryPendingTeardowns()); + } + + [Fact] + public void MinimalHost_ExitPositionRetainsExactRecordFrame() + { + const uint guid = 0x70000048u; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + record.FullCellId = 0x01010123u; + record.WorldEntity = new AcDream.Core.World.WorldEntity + { + Id = 1_000_123u, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = new Vector3(12f, 34f, 56f), + Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f), + MeshRefs = Array.Empty(), + }; + EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0); + + Assert.Equal(record.FullCellId, host.Position.ObjCellId); + Assert.Equal(record.WorldEntity.Position, host.Position.Frame.Origin); + Assert.Equal(record.WorldEntity.Rotation, host.Position.Frame.Orientation); + } + + [Fact] + public void FullHost_ExitSnapshotRetainsOldFrameAcrossSameGuidReplacement() + { + const uint departingGuid = 0x70000049u; + const uint watcherGuid = 0x7000004Au; + var delivered = new List(); + LiveEntityRuntime runtime = null!; + runtime = Runtime(record => + { + if (record.ServerGuid == departingGuid) + { + LiveEntityRecord replacement = runtime.RegisterLiveEntity( + Spawn(departingGuid, 2)).Record!; + replacement.FullCellId = 0x02020202u; + replacement.WorldEntity = Entity( + departingGuid, + new Vector3(999f, 999f, 999f), + Quaternion.Identity); + runtime.InstallPhysicsHost(replacement, Host(departingGuid)); + } + CleanPhysicsHost(record); + }); + LiveEntityRecord departing = runtime.RegisterLiveEntity(Spawn(departingGuid, 1)).Record!; + LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; + departing.FullCellId = 0x01010123u; + Quaternion departingRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f); + departing.WorldEntity = Entity( + departingGuid, + new Vector3(12f, 34f, 56f), + departingRotation); + watcherRecord.WorldEntity = Entity(watcherGuid, Vector3.Zero, Quaternion.Identity); + IPhysicsObjHost? Resolve(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host : null; + var body = new PhysicsBody + { + Position = departing.WorldEntity.Position, + Orientation = departingRotation, + }; + var remote = new RemoteMotion(body); + runtime.SetRemoteMotionRuntime(departingGuid, remote); + var departingHost = new EntityPhysicsHost( + departingGuid, + getPosition: () => new Position( + departing.FullCellId, + departing.WorldEntity?.Position ?? body.Position, + body.Orientation), + getVelocity: () => body.Velocity, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0, + physicsTimerTime: () => 0, + getObjectA: Resolve, + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + var watcher = new EntityPhysicsHost( + watcherGuid, + getPosition: () => new Position(0x01010001u, Vector3.Zero, Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0, + physicsTimerTime: () => 0, + getObjectA: Resolve, + handleUpdateTarget: delivered.Add, + interruptCurrentMovement: () => { }); + runtime.InstallPhysicsHost(departing, departingHost); + runtime.InstallPhysicsHost(watcherRecord, watcher); + watcher.SetTarget(0, departingGuid, 0.5f, 0.5); + delivered.Clear(); + + Assert.True(runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(departingGuid, InstanceSequence: 1), + isLocalPlayer: false)); + + TargetInfo exit = Assert.Single(delivered); + Assert.Equal(TargetStatus.ExitWorld, exit.Status); + Assert.Equal(0x01010123u, exit.TargetPosition.ObjCellId); + Assert.Equal(new Vector3(12f, 34f, 56f), exit.TargetPosition.Frame.Origin); + Assert.Equal(departingRotation, exit.TargetPosition.Frame.Orientation); + Assert.Equal(0x01010123u, remote.CellId); + } + + [Fact] + public void ActiveNonVisibleRecord_CanOwnResolvablePhysicsHost() + { + const uint guid = 0x7000004Bu; + var runtime = Runtime(); + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0); + runtime.InstallPhysicsHost(record, host); + + Assert.Empty(runtime.WorldEntities); + Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved)); + Assert.Same(host, resolved); + } + + private static void CleanPhysicsHost(LiveEntityRecord record) + { + if (record.PhysicsHost is not EntityPhysicsHost host) + return; + host.PositionManager.UnStick(); + host.ClearTarget(); + host.NotifyExitWorld(); + } + + private static LiveEntityRuntime Runtime(Action? teardown = null) => + teardown is null + ? new LiveEntityRuntime( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })) + : new LiveEntityRuntime( + new GpuWorldState(), + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), + teardown); + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) => + new( + Guid: guid, + Position: null, + SetupTableId: null, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: instance); + + private static AcDream.Core.World.WorldEntity Entity( + uint guid, + Vector3 position, + Quaternion rotation) => + new() + { + Id = 1_000_000u + (guid & 0xFFFFu), + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = position, + Rotation = rotation, + MeshRefs = Array.Empty(), + }; + + private static EntityPhysicsHost Host( + uint guid, + Func? resolve = null, + Vector3? position = null) => + new( + guid, + getPosition: () => new Position( + 0x01010001u, + position ?? Vector3.Zero, + Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0, + physicsTimerTime: () => 0, + getObjectA: resolve ?? (_ => null), + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + + private static EntityPhysicsHost CallbackHost( + uint guid, + Action handleUpdateTarget, + Action interruptCurrentMovement) => + new( + guid, + getPosition: () => new Position( + 0x01010001u, + Vector3.Zero, + Quaternion.Identity), + getVelocity: () => Vector3.Zero, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => 1f, + curTime: () => 0, + physicsTimerTime: () => 0, + getObjectA: _ => null, + handleUpdateTarget, + interruptCurrentMovement); + + private sealed class HostConsumerMotion : + ILiveEntityRemoteMotionRuntime, + ILiveEntityPhysicsHostConsumer + { + private Func? _read; + public PhysicsBody Body { get; } = new(); + public IPhysicsObjHost? Host => _read?.Invoke(); + public void BindPhysicsHost(Func read) + { + if (_read is not null) + throw new InvalidOperationException("already bound"); + _read = read; + } + } + + private sealed class ThrowingCellConsumerMotion : + ILiveEntityRemoteMotionRuntime, + ILiveEntityCanonicalRuntimeConsumer + { + private bool _failOnce = true; + public PhysicsBody Body { get; } = new(); + public void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell) + { + ArgumentNullException.ThrowIfNull(readPhysicsHost); + ArgumentNullException.ThrowIfNull(readCell); + ArgumentNullException.ThrowIfNull(writeCell); + if (_failOnce) + { + _failOnce = false; + throw new InvalidOperationException("fixture context bind failure"); + } + } + } + + private sealed class AlternatingBodyMotion( + PhysicsBody first, + PhysicsBody second) : ILiveEntityRemoteMotionRuntime + { + private int _reads; + public PhysicsBody Body => _reads++ == 0 ? first : second; + } + + private sealed class SequenceBodyMotion(params PhysicsBody[] bodies) : + ILiveEntityRemoteMotionRuntime + { + public int ReadCount { get; private set; } + + public PhysicsBody Body + { + get + { + int index = Math.Min(ReadCount, bodies.Length - 1); + ReadCount++; + return bodies[index]; + } + } + } + + private sealed class CallbackCanonicalMotion(Action callback) : + ILiveEntityRemoteMotionRuntime, + ILiveEntityCanonicalRuntimeConsumer + { + public PhysicsBody Body { get; } = new(); + + public void BindCanonicalRuntime( + Func readPhysicsHost, + Func readCell, + Action writeCell) + { + ArgumentNullException.ThrowIfNull(readPhysicsHost); + ArgumentNullException.ThrowIfNull(readCell); + ArgumentNullException.ThrowIfNull(writeCell); + callback(); + } + } +} diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs index 0202c92b..04436ad3 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs @@ -1,6 +1,7 @@ using System; using System.Numerics; using AcDream.App.Input; +using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; diff --git a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs index 55701c73..6dd2acbe 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs @@ -62,6 +62,9 @@ internal sealed class R5Host : IPhysicsObjHost public IPhysicsObjHost? GetObjectA(uint id) => World.TryGetValue(id, out var h) && h.Resolvable ? h : null; + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => + _targetManager?.GetRelationshipTarget(objectId); + public void HandleUpdateTarget(TargetInfo info) { HandleUpdateTargetCalls.Add(info); @@ -79,12 +82,14 @@ internal sealed class R5Host : IPhysicsObjHost public void ClearTarget() => _targetManager?.ClearTarget(); - public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info); + public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) => + _targetManager?.ReceiveUpdate(info, sender); - public void AddVoyeur(uint watcherId, float radius, double quantum) - => TargetManager.AddVoyeur(watcherId, radius, quantum); + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) + => TargetManager.AddVoyeur(watcher, radius, quantum); - public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId); + public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) => + _targetManager?.RemoveVoyeur(watcherId, expectedWatcher); // ── test helpers ─────────────────────────────────────────────────────── public void SetOrigin(Vector3 origin) diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index cfbfb27e..f75e054d 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -352,6 +352,8 @@ internal sealed class RemoteChaseHarness public double PhysicsTimerTime => _h.Now; public IPhysicsObjHost? GetObjectA(uint id) => id == PlayerGuid ? _h._playerHost : null; + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) + => GetObjectA(objectId); public void HandleUpdateTarget(TargetInfo info) { @@ -373,9 +375,9 @@ internal sealed class RemoteChaseHarness } public void ClearTarget() => _h._targetArmed = false; - public void ReceiveTargetUpdate(TargetInfo info) { } - public void AddVoyeur(uint watcherId, float radius, double quantum) { } - public void RemoveVoyeur(uint watcherId) { } + public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { } + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { } + public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { } } private sealed class TargetHost : IPhysicsObjHost @@ -391,13 +393,14 @@ internal sealed class RemoteChaseHarness public double CurTime => _h.Now; public double PhysicsTimerTime => _h.Now; public IPhysicsObjHost? GetObjectA(uint id) => null; + public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => null; public void HandleUpdateTarget(TargetInfo info) { } public void InterruptCurrentMovement() { } public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { } public void ClearTarget() { } - public void ReceiveTargetUpdate(TargetInfo info) { } - public void AddVoyeur(uint watcherId, float radius, double quantum) { } - public void RemoveVoyeur(uint watcherId) { } + public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { } + public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { } + public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { } } // ── The per-tick pipeline (GameWindow.TickAnimations order) ──────────── diff --git a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs index 744785df..4c8bd59f 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs @@ -218,11 +218,11 @@ public sealed class StickyManagerTests var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f }; var target = new R5Host(20u, world); var sticky = new StickyManager(self); + target.Resolvable = false; sticky.StickTo(target.Id, 0.5f, 1.0f); - // Cache a position via HandleUpdateTarget, then make the target vanish. + // Cache a position without ever establishing an exact target token. var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity); sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); - target.Resolvable = false; // GetObjectA(target) → null → fall back to cached var frame = new MotionDeltaFrame(); sticky.AdjustOffset(frame, quantum: 0.1); diff --git a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs index 0fd2130b..549e9b04 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs @@ -76,7 +76,9 @@ public sealed class TargetManagerTests int before = self.HandleUpdateTargetCalls.Count; var p = new Position(1u, Vector3.Zero, Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p)); + self.TargetManager.ReceiveUpdate( + new TargetInfo(999u, TargetStatus.Ok, p, p), + target); Assert.Equal(before, self.HandleUpdateTargetCalls.Count); } @@ -88,7 +90,9 @@ public sealed class TargetManagerTests self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); var p = new Position(1u, Vector3.Zero, Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p)); + self.TargetManager.ReceiveUpdate( + new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p), + target); Assert.Null(self.TargetManager.TargetInfo); // cleared Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed @@ -102,7 +106,9 @@ public sealed class TargetManagerTests self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); + self.TargetManager.ReceiveUpdate( + new TargetInfo(target.Id, TargetStatus.Ok, tp, tp), + target); // self→target interp position (0,5,0) normalized = +Y. var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading; @@ -110,6 +116,46 @@ public sealed class TargetManagerTests Assert.Equal(1f, heading.Y, 3); } + [Fact] + public void ReceiveUpdate_SameGuidReplacementSender_IsIgnored() + { + var (self, originalTarget, world) = TwoHosts(); + self.TargetManager.SetTarget(0, originalTarget.Id, 1.0f, 0.0); + self.HandleUpdateTargetCalls.Clear(); + var replacement = new R5Host(originalTarget.Id, world); + var p = new Position(1u, new Vector3(99f, 0f, 0f), Quaternion.Identity); + + self.ReceiveTargetUpdate( + new TargetInfo(originalTarget.Id, TargetStatus.ExitWorld, p, p), + replacement); + + Assert.Empty(self.HandleUpdateTargetCalls); + Assert.NotNull(self.TargetManager.TargetInfo); + Assert.Same( + originalTarget, + self.TargetManager.GetRelationshipTarget(originalTarget.Id)); + } + + [Fact] + public void StickyAdjustOffset_UsesExactTargetInsteadOfSameGuidReplacement() + { + var (self, originalTarget, world) = TwoHosts(); + self.SetOrigin(Vector3.Zero); + originalTarget.SetOrigin(new Vector3(5f, 0f, 0f)); + self.PositionManager.StickTo(originalTarget.Id, 0.5f, 1f); + var replacement = new R5Host(originalTarget.Id, world); + replacement.SetOrigin(new Vector3(10f, 0f, 0f)); + originalTarget.SetOrigin(new Vector3(-5f, 0f, 0f)); + var delta = new MotionDeltaFrame(); + + self.PositionManager.AdjustOffset(delta, 0.1); + + Assert.True(delta.Origin.X < 0f); + Assert.Same( + originalTarget, + self.TargetManager.GetRelationshipTarget(originalTarget.Id)); + } + [Fact] public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds() { @@ -133,9 +179,8 @@ public sealed class TargetManagerTests public void HandleTargetting_Throttles_Within500ms() { var (self, target, _) = TwoHosts(); - var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0); // seed a voyeur directly + advance so a sweep WOULD send if not throttled. - target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0); + target.TargetManager.AddVoyeur(self, 0.1f, 0.0); target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius target.PhysicsTimerTime = 0.3; // < 0.5 throttle @@ -186,12 +231,12 @@ public sealed class TargetManagerTests { var (self, target, _) = TwoHosts(); target.SetOrigin(Vector3.Zero); - target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0); + target.TargetManager.AddVoyeur(self, radius: 1.0f, quantum: 0.0); var voyeur = target.TargetManager.VoyeurTable![self.Id]; Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); target.SetOrigin(new Vector3(5f, 0f, 0f)); - target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only + target.TargetManager.AddVoyeur(self, radius: 2.5f, quantum: 0.3); // existing → update only Assert.Equal(2.5f, voyeur.Radius); Assert.Equal(0.3, voyeur.Quantum); @@ -202,10 +247,27 @@ public sealed class TargetManagerTests public void RemoveVoyeur_RemovesEntry() { var (self, target, _) = TwoHosts(); - target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0); - Assert.True(target.TargetManager.RemoveVoyeur(self.Id)); + target.TargetManager.AddVoyeur(self, 1.0f, 0.0); + Assert.True(target.TargetManager.RemoveVoyeur(self.Id, self)); Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); - Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone + Assert.False(target.TargetManager.RemoveVoyeur(self.Id, self)); // already gone + } + + [Fact] + public void HiddenWatcher_GuidReuseCannotRedirectOrRemoveExactSubscription() + { + var (watcher, target, world) = TwoHosts(); + watcher.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); + watcher.HandleUpdateTargetCalls.Clear(); + watcher.Resolvable = false; + var replacement = new R5Host(watcher.Id, world); + + target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); + + Assert.Single(watcher.HandleUpdateTargetCalls); + Assert.Empty(replacement.HandleUpdateTargetCalls); + Assert.False(target.TargetManager.RemoveVoyeur(watcher.Id, replacement)); + Assert.True(target.TargetManager.RemoveVoyeur(watcher.Id, watcher)); } [Fact]