refactor(world): canonicalize live physics host ownership
This commit is contained in:
parent
5882b308c1
commit
fcb66198fc
21 changed files with 1546 additions and 323 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
314
src/AcDream.App/Physics/EntityPhysicsHost.cs
Normal file
314
src/AcDream.App/Physics/EntityPhysicsHost.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
|
||||
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
|
||||
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
|
||||
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
|
||||
/// and stored on the exact <c>LiveEntityRecord</c>, so
|
||||
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
|
||||
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
|
||||
/// needs.
|
||||
///
|
||||
/// <para>Owns a <see cref="TargetManager"/> (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
|
||||
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> 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
|
||||
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
|
||||
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
|
||||
///
|
||||
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
|
||||
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
|
||||
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
|
||||
/// behaviorally identical because the empty facade no-ops until its first
|
||||
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
|
||||
/// deliveries to the injected MoveToManager fan FIRST, then the
|
||||
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
|
||||
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
|
||||
/// @0x00512c1a).</para>
|
||||
/// </summary>
|
||||
public sealed class EntityPhysicsHost : IPhysicsObjHost
|
||||
{
|
||||
private Func<Position> _getPosition;
|
||||
private Func<Vector3> _getVelocity;
|
||||
private Func<float> _getRadius;
|
||||
private Func<bool> _inContact;
|
||||
private Func<float?> _minterpMaxSpeed;
|
||||
private Func<double> _curTime;
|
||||
private Func<double> _physicsTimerTime;
|
||||
private Func<uint, IPhysicsObjHost?> _getObjectA;
|
||||
private Action<TargetInfo> _handleUpdateTarget;
|
||||
private Action _interruptCurrentMovement;
|
||||
private readonly TargetManager _targetManager;
|
||||
|
||||
public EntityPhysicsHost(
|
||||
uint id,
|
||||
Func<Position> getPosition,
|
||||
Func<Vector3> getVelocity,
|
||||
Func<float> getRadius,
|
||||
Func<bool> inContact,
|
||||
Func<float?> minterpMaxSpeed,
|
||||
Func<double> curTime,
|
||||
Func<double> physicsTimerTime,
|
||||
Func<uint, IPhysicsObjHost?> getObjectA,
|
||||
Action<TargetInfo> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void Rebind(
|
||||
Func<Position> getPosition,
|
||||
Func<Vector3> getVelocity,
|
||||
Func<float> getRadius,
|
||||
Func<bool> inContact,
|
||||
Func<float?> minterpMaxSpeed,
|
||||
Func<double> curTime,
|
||||
Func<double> physicsTimerTime,
|
||||
Func<uint, IPhysicsObjHost?> getObjectA,
|
||||
Action<TargetInfo> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a freshly composed delegate configuration while retaining this
|
||||
/// host's exact identity and manager instances.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost CreateMinimal(
|
||||
LiveEntityRecord record,
|
||||
Func<uint, IPhysicsObjHost?> resolve,
|
||||
Func<double> 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();
|
||||
|
||||
/// <summary>The owned voyeur manager (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>).</summary>
|
||||
public TargetManager TargetManager => _targetManager;
|
||||
|
||||
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
|
||||
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
|
||||
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
|
||||
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
|
||||
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
|
||||
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) ─────────────────
|
||||
|
||||
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
|
||||
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
|
||||
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
|
||||
/// managers' <c>UseTime</c>.</summary>
|
||||
public void HandleTargetting() => _targetManager.HandleTargetting();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
|
||||
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — 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.</summary>
|
||||
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>
|
||||
/// A Hidden object remains logically alive but is removed from the cell
|
||||
/// lookup/interaction surface. Retail's DetectionManager sends
|
||||
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
|
||||
/// not ported yet, so the App bridges the same unavailability edge through
|
||||
/// the existing target fan-out. The non-Ok update tears down watcher
|
||||
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
|
||||
/// UnHide re-subscription receives a fresh initial snapshot. The object's
|
||||
/// own watcher role is deliberately preserved.
|
||||
/// </summary>
|
||||
public void NotifyHidden() =>
|
||||
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
||||
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
||||
/// (drop this entity's OWN subscription) then
|
||||
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
|
||||
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
|
||||
/// non-Ok teardown path). Called after a teleport placement.</summary>
|
||||
public void NotifyTeleported()
|
||||
{
|
||||
_targetManager.ClearTarget();
|
||||
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
|
@ -22,7 +21,8 @@ namespace AcDream.App.Physics;
|
|||
/// </para>
|
||||
/// </summary>
|
||||
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<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
|
||||
private bool _fullPhysicsHostBound;
|
||||
public EntityPhysicsHost? Host => _fullPhysicsHostBound
|
||||
? _physicsHostReader?.Invoke() as EntityPhysicsHost
|
||||
: null;
|
||||
/// <summary>
|
||||
/// 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<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
|
||||
Func<uint> readCell,
|
||||
Action<uint> 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<uint> read, Action<uint> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
|
||||
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
|
||||
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
|
||||
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
|
||||
/// and registered in <c>GameWindow._physicsHosts</c> (guid → host), so
|
||||
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
|
||||
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
|
||||
/// needs.
|
||||
///
|
||||
/// <para>Owns a <see cref="TargetManager"/> (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
|
||||
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> 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
|
||||
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
|
||||
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
|
||||
///
|
||||
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
|
||||
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
|
||||
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
|
||||
/// behaviorally identical because the empty facade no-ops until its first
|
||||
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
|
||||
/// deliveries to the injected MoveToManager fan FIRST, then the
|
||||
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
|
||||
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
|
||||
/// @0x00512c1a).</para>
|
||||
/// </summary>
|
||||
public sealed class EntityPhysicsHost : IPhysicsObjHost
|
||||
{
|
||||
private readonly Func<Position> _getPosition;
|
||||
private readonly Func<Vector3> _getVelocity;
|
||||
private readonly Func<float> _getRadius;
|
||||
private readonly Func<bool> _inContact;
|
||||
private readonly Func<float?> _minterpMaxSpeed;
|
||||
private readonly Func<double> _curTime;
|
||||
private readonly Func<double> _physicsTimerTime;
|
||||
private readonly Func<uint, IPhysicsObjHost?> _getObjectA;
|
||||
private readonly Action<TargetInfo> _handleUpdateTarget;
|
||||
private readonly Action _interruptCurrentMovement;
|
||||
private readonly TargetManager _targetManager;
|
||||
|
||||
public EntityPhysicsHost(
|
||||
uint id,
|
||||
Func<Position> getPosition,
|
||||
Func<Vector3> getVelocity,
|
||||
Func<float> getRadius,
|
||||
Func<bool> inContact,
|
||||
Func<float?> minterpMaxSpeed,
|
||||
Func<double> curTime,
|
||||
Func<double> physicsTimerTime,
|
||||
Func<uint, IPhysicsObjHost?> getObjectA,
|
||||
Action<TargetInfo> 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();
|
||||
|
||||
/// <summary>The owned voyeur manager (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>).</summary>
|
||||
public TargetManager TargetManager => _targetManager;
|
||||
|
||||
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
|
||||
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
|
||||
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
|
||||
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
|
||||
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
|
||||
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) ─────────────────
|
||||
|
||||
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
|
||||
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
|
||||
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
|
||||
/// managers' <c>UseTime</c>.</summary>
|
||||
public void HandleTargetting() => _targetManager.HandleTargetting();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
|
||||
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — 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.</summary>
|
||||
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>
|
||||
/// A Hidden object remains logically alive but is removed from the cell
|
||||
/// lookup/interaction surface. Retail's DetectionManager sends
|
||||
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
|
||||
/// not ported yet, so the App bridges the same unavailability edge through
|
||||
/// the existing target fan-out. The non-Ok update tears down watcher
|
||||
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
|
||||
/// UnHide re-subscription receives a fresh initial snapshot. The object's
|
||||
/// own watcher role is deliberately preserved.
|
||||
/// </summary>
|
||||
public void NotifyHidden() =>
|
||||
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
||||
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
||||
/// (drop this entity's OWN subscription) then
|
||||
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
|
||||
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
|
||||
/// non-Ok teardown path). Called after a teleport placement.</summary>
|
||||
public void NotifyTeleported()
|
||||
{
|
||||
_targetManager.ClearTarget();
|
||||
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
|
||||
}
|
||||
}
|
||||
|
|
@ -777,7 +777,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
|||
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> 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<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _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
|
|||
/// </summary>
|
||||
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<bool> 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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,29 @@ public interface ILiveEntityRemoteMotionRuntime
|
|||
PhysicsBody Body { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional seam for a remote-motion component that reads the exact
|
||||
/// incarnation's canonical movement-manager host from
|
||||
/// <see cref="LiveEntityRecord"/>.
|
||||
/// </summary>
|
||||
public interface ILiveEntityPhysicsHostConsumer
|
||||
{
|
||||
void BindPhysicsHost(Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> read);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface ILiveEntityCanonicalRuntimeConsumer
|
||||
{
|
||||
void BindCanonicalRuntime(
|
||||
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
|
||||
Func<uint> readCell,
|
||||
Action<uint> writeCell);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional seam for a remote-motion component that consumes the canonical
|
||||
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. 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<AcDream.Core.Physics.Motion.IPhysicsObjHost?> 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<uint> readCell = () => incarnation.FullCellId;
|
||||
Action<uint> 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;
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ namespace AcDream.Core.Physics.Motion;
|
|||
/// <c>RemoteMotion</c> or the local player), wiring the accessors to the live
|
||||
/// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> /
|
||||
/// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns.
|
||||
/// <see cref="GetObjectA"/> is backed by the App's live entity table
|
||||
/// (<c>_entitiesByServerGuid</c>), giving the voyeur round-trip its
|
||||
/// cross-entity delivery path.</para>
|
||||
/// <see cref="GetObjectA"/> is backed by the App's canonical
|
||||
/// <c>LiveEntityRuntime</c> records, giving the voyeur round-trip its
|
||||
/// cross-entity delivery path without a second GUID index.</para>
|
||||
/// </summary>
|
||||
public interface IPhysicsObjHost
|
||||
{
|
||||
|
|
@ -57,11 +57,21 @@ public interface IPhysicsObjHost
|
|||
double PhysicsTimerTime { get; }
|
||||
|
||||
/// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another
|
||||
/// physics object by guid, or <c>null</c> 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.</summary>
|
||||
IPhysicsObjHost? GetObjectA(uint id);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
IPhysicsObjHost? GetRelationshipTarget(uint objectId);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a
|
||||
/// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/>
|
||||
/// (move-to steering) AND <see cref="PositionManager"/> (sticky follow).
|
||||
|
|
@ -86,15 +96,19 @@ public interface IPhysicsObjHost
|
|||
/// <summary>Retail <c>CPhysicsObj::receive_target_update</c> →
|
||||
/// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's
|
||||
/// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary>
|
||||
void ReceiveTargetUpdate(TargetInfo info);
|
||||
void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> →
|
||||
/// <see cref="TargetManager.AddVoyeur"/> (lazily creating the
|
||||
/// TargetManager). Called on the TARGET when a watcher subscribes.</summary>
|
||||
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.</summary>
|
||||
void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> →
|
||||
/// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a
|
||||
/// watcher unsubscribes.</summary>
|
||||
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.</summary>
|
||||
void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<uint, TargettedVoyeurInfo>? _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.</summary>
|
||||
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
|
||||
|
||||
/// <summary>The pointer-like target identity captured by SetTarget.</summary>
|
||||
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
|
||||
_targetInfo is { } info && info.ObjectId == objectId
|
||||
? _targetHost
|
||||
: null;
|
||||
|
||||
// ── watcher role ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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.
|
||||
/// </summary>
|
||||
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 (<c>Ok</c>).
|
||||
/// </summary>
|
||||
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<uint, TargettedVoyeurInfo>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90).</summary>
|
||||
public bool RemoveVoyeur(uint watcherId)
|
||||
=> _voyeurTable?.Remove(watcherId) ?? false;
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90). Removes a
|
||||
/// relationship only when it still belongs to the exact watcher
|
||||
/// incarnation that established it in the App lifetime model.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::HandleTargetting</c> (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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ namespace AcDream.Core.Physics.Motion;
|
|||
/// </summary>
|
||||
public sealed class TargettedVoyeurInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal IPhysicsObjHost WatcherHost { get; }
|
||||
|
||||
/// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary>
|
||||
public uint ObjectId { get; }
|
||||
|
||||
|
|
@ -27,10 +35,23 @@ public sealed class TargettedVoyeurInfo
|
|||
/// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ArgumentException>(() =>
|
||||
runtime.InstallPhysicsHost(first, Host(guid + 1)));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.InstallPhysicsHost(first, Host(guid)));
|
||||
|
||||
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
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<StickyManager>(positionManager.Sticky);
|
||||
ConstraintManager constraint = Assert.IsType<ConstraintManager>(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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<AggregateException>(() => 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<AggregateException>(() => 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<AcDream.Core.World.MeshRef>(),
|
||||
};
|
||||
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<TargetInfo>();
|
||||
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<LiveEntityRecord>? 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<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
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<AcDream.Core.World.MeshRef>(),
|
||||
};
|
||||
|
||||
private static EntityPhysicsHost Host(
|
||||
uint guid,
|
||||
Func<uint, IPhysicsObjHost?>? 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<TargetInfo> 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<IPhysicsObjHost?>? _read;
|
||||
public PhysicsBody Body { get; } = new();
|
||||
public IPhysicsObjHost? Host => _read?.Invoke();
|
||||
public void BindPhysicsHost(Func<IPhysicsObjHost?> 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<IPhysicsObjHost?> readPhysicsHost,
|
||||
Func<uint> readCell,
|
||||
Action<uint> 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<IPhysicsObjHost?> readPhysicsHost,
|
||||
Func<uint> readCell,
|
||||
Action<uint> writeCell)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(readPhysicsHost);
|
||||
ArgumentNullException.ThrowIfNull(readCell);
|
||||
ArgumentNullException.ThrowIfNull(writeCell);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) ────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue