refactor(world): extract live appearance and parenting
Move ObjDesc, Parent, Pickup, child-ready, and retained projection recovery into LiveEntityHydrationController while preserving retail timestamp and leave-world semantics. Pin ready publication to exact projection authority and restore attached descendant chains synchronously.
This commit is contained in:
parent
40352d5a7a
commit
fe5514967c
9 changed files with 1098 additions and 199 deletions
|
|
@ -204,6 +204,13 @@ on ParentEvent(parent, child):
|
||||||
strictly advance the child's Position timestamp
|
strictly advance the child's Position timestamp
|
||||||
apply parent + placement without creating a second position counter
|
apply parent + placement without creating a second position counter
|
||||||
|
|
||||||
|
on CreateObject with Parent but no AnimationFrame field:
|
||||||
|
PhysicsDesc construction initializes animframe_id to 0
|
||||||
|
PhysicsDesc::UnPack explicitly leaves animframe_id at 0 when absent
|
||||||
|
set_description installs placement 0 before the parent relation is realized
|
||||||
|
therefore treat absent wire AnimationFrame as placement 0, not as a
|
||||||
|
missing relation
|
||||||
|
|
||||||
on PickupEvent(object):
|
on PickupEvent(object):
|
||||||
require the exact live Instance
|
require the exact live Instance
|
||||||
strictly advance the shared Position timestamp
|
strictly advance the shared Position timestamp
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,12 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
lock (_datLock)
|
lock (_datLock)
|
||||||
{
|
{
|
||||||
if (spawn.ParentGuid is { } parentGuid and not 0
|
if (spawn.ParentGuid is { } parentGuid and not 0
|
||||||
&& spawn.ParentLocation is { } parentLocation
|
&& spawn.ParentLocation is { } parentLocation)
|
||||||
&& spawn.PlacementId is { } placementId)
|
|
||||||
{
|
{
|
||||||
|
// PhysicsDesc::PhysicsDesc / UnPack (0x0051D4D0 /
|
||||||
|
// 0x0051DDD0) default an absent AnimationFrame to zero.
|
||||||
|
// Parent remains a complete relation in that case.
|
||||||
|
uint placementId = spawn.PlacementId ?? 0u;
|
||||||
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||||
parentGuid,
|
parentGuid,
|
||||||
spawn.Guid,
|
spawn.Guid,
|
||||||
|
|
@ -118,6 +121,51 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>SmartBox::UpdateVisualDesc @ 0x00451F40</c> replaces an
|
||||||
|
/// attached child's visual description without requiring a world
|
||||||
|
/// Position. Re-realize the last accepted relation from the newest
|
||||||
|
/// canonical snapshot while retaining the logical entity.
|
||||||
|
/// </summary>
|
||||||
|
internal bool TryApplyAttachedAppearance(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedObjDescAuthorityVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
if (!_liveEntities.IsCurrentObjDescAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
expectedObjDescAuthorityVersion)
|
||||||
|
|| expectedRecord.ProjectionKind is not
|
||||||
|
LiveEntityProjectionKind.Attached
|
||||||
|
|| !expectedRecord.IsSpatiallyProjected
|
||||||
|
|| expectedRecord.WorldEntity is not { } expectedEntity
|
||||||
|
|| !Relations.RestoreLastAccepted(expectedRecord.ServerGuid))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool projected = ResolveAndTryRealize(expectedRecord.ServerGuid);
|
||||||
|
if (projected)
|
||||||
|
{
|
||||||
|
// WithdrawPriorProjection removes the complete attached
|
||||||
|
// subtree and retains each descendant relation for recovery.
|
||||||
|
// The ObjDesc transaction is not published until that same
|
||||||
|
// subtree is synchronously whole again.
|
||||||
|
RetryWaitingDescendants(expectedRecord.ServerGuid);
|
||||||
|
}
|
||||||
|
return projected
|
||||||
|
&& _liveEntities.IsCurrentObjDescAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
expectedObjDescAuthorityVersion)
|
||||||
|
&& expectedRecord.ProjectionKind is
|
||||||
|
LiveEntityProjectionKind.Attached
|
||||||
|
&& expectedRecord.IsSpatiallyProjected
|
||||||
|
&& ReferenceEquals(expectedRecord.WorldEntity, expectedEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Completes attachment projection after a root world entity has been
|
/// Completes attachment projection after a root world entity has been
|
||||||
/// registered. Relation acceptance intentionally happens before root
|
/// registered. Relation acceptance intentionally happens before root
|
||||||
|
|
@ -448,46 +496,31 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
// CPhysicsObj::set_hidden (0x00514C60).
|
// CPhysicsObj::set_hidden (0x00514C60).
|
||||||
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
|
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
|
||||||
}
|
}
|
||||||
ulong readyCreateIntegrationVersion = childRecord.CreateIntegrationVersion;
|
|
||||||
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||||
$"location={parentLocation} placement={placement}");
|
$"location={parentLocation} placement={placement}");
|
||||||
Relations.MarkProjected(pending, candidateKind);
|
Relations.MarkProjected(pending, candidateKind);
|
||||||
|
LiveEntityReadyCandidate readyCandidate =
|
||||||
|
LiveEntityReadyCandidate.Capture(childRecord);
|
||||||
ProjectionPoseReady?.Invoke(childGuid);
|
ProjectionPoseReady?.Invoke(childGuid);
|
||||||
return PublishEntityReadyExact(
|
return PublishEntityReadyExact(
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
childRecord,
|
readyCandidate,
|
||||||
readyCreateIntegrationVersion,
|
|
||||||
entity,
|
|
||||||
EntityReady);
|
EntityReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool PublishEntityReadyExact(
|
internal static bool PublishEntityReadyExact(
|
||||||
LiveEntityRuntime runtime,
|
LiveEntityRuntime runtime,
|
||||||
LiveEntityRecord expectedRecord,
|
LiveEntityReadyCandidate candidate,
|
||||||
ulong expectedCreateIntegrationVersion,
|
|
||||||
WorldEntity expectedEntity,
|
|
||||||
Action<LiveEntityReadyCandidate>? publish)
|
Action<LiveEntityReadyCandidate>? publish)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(runtime);
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
if (!candidate.IsCurrent(runtime))
|
||||||
ArgumentNullException.ThrowIfNull(expectedEntity);
|
|
||||||
if (!runtime.IsCurrentCreateIntegration(
|
|
||||||
expectedRecord,
|
|
||||||
expectedCreateIntegrationVersion)
|
|
||||||
|| !ReferenceEquals(expectedRecord.WorldEntity, expectedEntity))
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
publish?.Invoke(new LiveEntityReadyCandidate(
|
publish?.Invoke(candidate);
|
||||||
expectedRecord,
|
return candidate.IsCurrent(runtime);
|
||||||
expectedCreateIntegrationVersion));
|
|
||||||
return runtime.IsCurrentCreateIntegration(
|
|
||||||
expectedRecord,
|
|
||||||
expectedCreateIntegrationVersion)
|
|
||||||
&& ReferenceEquals(expectedRecord.WorldEntity, expectedEntity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PublishChildPose(
|
private void PublishChildPose(
|
||||||
|
|
|
||||||
|
|
@ -749,8 +749,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
/// guid. Captured before the renderability gate so no-position inventory /
|
/// guid. Captured before the renderability gate so no-position inventory /
|
||||||
/// parented children retain Setup and parent metadata; hydration rereads
|
/// parented children retain Setup and parent metadata; hydration rereads
|
||||||
/// this canonical snapshot after every relationship callback.
|
/// this canonical snapshot after every relationship callback.
|
||||||
/// <see cref="OnLiveAppearanceUpdated"/> reuses the cached position/setup/motion
|
/// <see cref="LiveEntityHydrationController.OnAppearance"/> reuses the cached
|
||||||
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
|
/// position/setup/motion fields when a 0xF625 ObjDescEvent carries only
|
||||||
|
/// updated visuals.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
|
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
|
||||||
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
|
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
|
||||||
|
|
@ -1750,6 +1751,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
item, container, placement, amount),
|
item, container, placement, amount),
|
||||||
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
|
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
|
||||||
|
|
||||||
|
AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null;
|
||||||
|
|
||||||
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
|
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
|
||||||
if (_options.RetailUi)
|
if (_options.RetailUi)
|
||||||
{
|
{
|
||||||
|
|
@ -2270,13 +2273,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_lightingSink!,
|
_lightingSink!,
|
||||||
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
|
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
|
||||||
|
|
||||||
|
parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
|
||||||
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
|
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
|
||||||
_dats!,
|
_dats!,
|
||||||
_datLock,
|
_datLock,
|
||||||
Objects,
|
Objects,
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
_effectPoses,
|
_effectPoses,
|
||||||
TryAcceptParentForRender,
|
parentAcceptance.TryAccept,
|
||||||
(childRecord, positionVersion, projectionVersion) =>
|
(childRecord, positionVersion, projectionVersion) =>
|
||||||
_liveEntityProjectionWithdrawal!.WithdrawExact(
|
_liveEntityProjectionWithdrawal!.WithdrawExact(
|
||||||
childRecord,
|
childRecord,
|
||||||
|
|
@ -2567,8 +2571,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
Objects,
|
Objects,
|
||||||
_datLock,
|
_datLock,
|
||||||
projectionMaterializer,
|
projectionMaterializer,
|
||||||
new AcDream.App.World.DelegateLiveEntitySpawnRelationshipSink(
|
new AcDream.App.World.LiveEntityRelationshipProjection(
|
||||||
_equippedChildRenderer.OnSpawn),
|
_equippedChildRenderer),
|
||||||
new AcDream.App.World.LiveEntityReadyPublisher(
|
new AcDream.App.World.LiveEntityReadyPublisher(
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
_entityEffects!,
|
_entityEffects!,
|
||||||
|
|
@ -2578,11 +2582,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
PublishLocalPhysicsTimestamps,
|
PublishLocalPhysicsTimestamps,
|
||||||
() => _playerServerGuid,
|
() => _playerServerGuid,
|
||||||
_options.DumpLiveSpawns ? Console.WriteLine : null);
|
_options.DumpLiveSpawns ? Console.WriteLine : null);
|
||||||
|
parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection);
|
||||||
networkUpdateBridge.Bind(
|
networkUpdateBridge.Bind(
|
||||||
new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink(
|
new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink(
|
||||||
RouteSameGenerationCreateObject));
|
RouteSameGenerationCreateObject));
|
||||||
_equippedChildRenderer.EntityReady += candidate =>
|
_equippedChildRenderer.EntityReady += candidate =>
|
||||||
_liveEntityHydration.OnEntityReady(candidate);
|
_liveEntityHydration.OnEntityReady(candidate);
|
||||||
|
_liveEntityHydration.AppearanceApplied += guid =>
|
||||||
|
{
|
||||||
|
if (guid == _playerServerGuid)
|
||||||
|
_paperdollDollDirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
|
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
|
||||||
// enter the world as the first character on the account, and stream
|
// enter the world as the first character on the account, and stream
|
||||||
|
|
@ -2823,7 +2833,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
Deleted: deletion => _inboundEntityEvents.Run(
|
Deleted: deletion => _inboundEntityEvents.Run(
|
||||||
this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
|
this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
|
||||||
PickedUp: pickup => _inboundEntityEvents.Run(
|
PickedUp: pickup => _inboundEntityEvents.Run(
|
||||||
this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)),
|
_liveEntityHydration!, pickup,
|
||||||
|
static (hydration, value) => hydration.OnPickup(value)),
|
||||||
MotionUpdated: motion => _inboundEntityEvents.Run(
|
MotionUpdated: motion => _inboundEntityEvents.Run(
|
||||||
this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
|
this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
|
||||||
PositionUpdated: position => _inboundEntityEvents.Run(
|
PositionUpdated: position => _inboundEntityEvents.Run(
|
||||||
|
|
@ -2833,11 +2844,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
StateUpdated: state => _inboundEntityEvents.Run(
|
StateUpdated: state => _inboundEntityEvents.Run(
|
||||||
this, state, static (window, value) => window.OnLiveStateUpdated(value)),
|
this, state, static (window, value) => window.OnLiveStateUpdated(value)),
|
||||||
ParentUpdated: parent => _inboundEntityEvents.Run(
|
ParentUpdated: parent => _inboundEntityEvents.Run(
|
||||||
this, parent, static (window, value) => window.OnLiveParentUpdated(value)),
|
_liveEntityHydration!, parent,
|
||||||
|
static (hydration, value) => hydration.OnParent(value)),
|
||||||
TeleportStarted: teleport => _inboundEntityEvents.Run(
|
TeleportStarted: teleport => _inboundEntityEvents.Run(
|
||||||
this, teleport, static (window, value) => window.OnTeleportStarted(value)),
|
this, teleport, static (window, value) => window.OnTeleportStarted(value)),
|
||||||
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
|
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
|
||||||
this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)),
|
_liveEntityHydration!, appearance,
|
||||||
|
static (hydration, value) => hydration.OnAppearance(value)),
|
||||||
PlayPhysicsScript: script => _inboundEntityEvents.Run(
|
PlayPhysicsScript: script => _inboundEntityEvents.Run(
|
||||||
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
|
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
|
||||||
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
|
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
|
||||||
|
|
@ -3037,46 +3050,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
candidate.Generation));
|
candidate.Generation));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server broadcast a <c>0xF625 ObjDescEvent</c> — a creature/player's
|
|
||||||
/// appearance changed (equip / unequip / tailoring / recipe result /
|
|
||||||
/// character option toggle). The wire payload only carries the new
|
|
||||||
/// ModelData (palette + texture + animpart changes), not position or
|
|
||||||
/// motion, so we splice it onto the cached spawn and rehydrate only the
|
|
||||||
/// visual projection. Retail <c>SmartBox::UpdateVisualDesc</c> calls
|
|
||||||
/// <c>CPhysicsObj::DoObjDescChangesFromDefault</c> on the existing object;
|
|
||||||
/// accordingly, entity identity and all animation/movement/physics state
|
|
||||||
/// survive while MeshRefs, palette, and part overrides are replaced.
|
|
||||||
/// </summary>
|
|
||||||
private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update)
|
|
||||||
{
|
|
||||||
if (!_liveEntities!.TryApplyObjDesc(update, out var newSpawn))
|
|
||||||
{
|
|
||||||
// Server can broadcast ObjDescEvent before we've seen a
|
|
||||||
// CreateObject for this guid (race on landblock entry, or
|
|
||||||
// if the entity is in a state we couldn't render). Drop —
|
|
||||||
// when CreateObject lands, ACE includes the same ModelData
|
|
||||||
// body inside it, so the appearance won't be lost.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
|
|
||||||
{
|
|
||||||
LiveEntityAppearanceUpdateState? appearanceState =
|
|
||||||
LiveEntityAppearanceBinding.Capture(_liveEntities, update.Guid);
|
|
||||||
_liveEntityHydration!.ApplyAppearance(
|
|
||||||
record,
|
|
||||||
newSpawn,
|
|
||||||
appearanceState);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slice 2: flag the paperdoll doll to re-clone the player's newly
|
|
||||||
// mutated appearance on the next doll pass (the C# analog of
|
|
||||||
// RedressCreature). The rebuild is deferred until the doll is visible.
|
|
||||||
if (update.Guid == _playerServerGuid)
|
|
||||||
_paperdollDollDirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
|
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
|
||||||
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
|
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
|
||||||
|
|
@ -3198,20 +3171,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
_entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
|
_entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
|
||||||
}
|
}
|
||||||
|
|
||||||
OnLiveAppearanceUpdated(refresh.Appearance);
|
_liveEntityHydration!.OnAppearance(refresh.Appearance);
|
||||||
|
|
||||||
if (refresh.Parent is { } parent
|
if (refresh.Parent is { } parent)
|
||||||
&& _liveEntities!.TryApplyCreateParent(parent, out _))
|
_liveEntityHydration.OnCreateParentAccepted(parent);
|
||||||
{
|
|
||||||
_equippedChildRenderer?.OnCreateParentAccepted(parent);
|
|
||||||
}
|
|
||||||
else if (refresh.Position is { } position)
|
else if (refresh.Position is { } position)
|
||||||
{
|
{
|
||||||
OnLivePositionUpdated(position);
|
OnLivePositionUpdated(position);
|
||||||
}
|
}
|
||||||
else if (refresh.Pickup is { } pickup)
|
else if (refresh.Pickup is { } pickup)
|
||||||
{
|
{
|
||||||
OnLiveEntityPickedUp(pickup);
|
_liveEntityHydration.OnPickup(pickup);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refresh.Movement is { } movement)
|
if (refresh.Movement is { } movement)
|
||||||
|
|
@ -3220,49 +3190,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||||||
OnLiveVectorUpdated(refresh.Vector);
|
OnLiveVectorUpdated(refresh.Vector);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retail PickupEvent is a POSITION_TS update, not object destruction.
|
|
||||||
/// It leaves the world projection while retaining the weenie, generation,
|
|
||||||
/// and every other timestamp for later inventory/parent events.
|
|
||||||
/// </summary>
|
|
||||||
private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup)
|
|
||||||
{
|
|
||||||
if (!_liveEntities!.TryApplyPickup(pickup, out _))
|
|
||||||
return;
|
|
||||||
|
|
||||||
AcDream.App.Rendering.ChildUnparentDisposition childDisposition =
|
|
||||||
_equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid)
|
|
||||||
?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
|
|
||||||
bool removed = childDisposition switch
|
|
||||||
{
|
|
||||||
AcDream.App.Rendering.ChildUnparentDisposition.Completed => true,
|
|
||||||
AcDream.App.Rendering.ChildUnparentDisposition.NotAttached => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (removed
|
|
||||||
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
|
||||||
{
|
|
||||||
Console.WriteLine(
|
|
||||||
$"live: pickup guid=0x{pickup.Guid:X8} instSeq={pickup.InstanceSequence} posSeq={pickup.PositionSequence}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ParentEvent may precede either CreateObject. The focused child renderer
|
|
||||||
/// retains it; its realization callback performs the canonical timestamp
|
|
||||||
/// acceptance immediately before changing the projection.
|
|
||||||
/// </summary>
|
|
||||||
private void OnLiveParentUpdated(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
|
|
||||||
{
|
|
||||||
_equippedChildRenderer?.OnParentEvent(update);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
|
|
||||||
{
|
|
||||||
return _liveEntities!.TryApplyParent(update, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PublishLocalPhysicsTimestamps(
|
private void PublishLocalPhysicsTimestamps(
|
||||||
uint guid,
|
uint guid,
|
||||||
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
|
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
namespace AcDream.App.World;
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
|
|
@ -29,19 +31,55 @@ internal interface ILiveEntityProjectionMaterializer
|
||||||
void ResetSessionState();
|
void ResetSessionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface ILiveEntitySpawnRelationshipSink
|
internal interface ILiveEntityRelationshipProjection
|
||||||
{
|
{
|
||||||
void OnSpawn(WorldSession.EntitySpawn spawn);
|
void OnSpawn(WorldSession.EntitySpawn spawn);
|
||||||
|
void OnParent(ParentEvent.Parsed update);
|
||||||
|
void OnCreateParentAccepted(CreateParentUpdate update);
|
||||||
|
ChildUnparentDisposition OnChildBecameUnparented(uint childGuid);
|
||||||
|
bool TryApplyAttachedAppearance(
|
||||||
|
LiveEntityRecord record,
|
||||||
|
ulong objDescAuthorityVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface ILiveEntityReadyPublisher
|
internal interface ILiveEntityReadyPublisher
|
||||||
{
|
{
|
||||||
bool Publish(LiveEntityRecord expectedRecord);
|
bool Publish(LiveEntityReadyCandidate candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal readonly record struct LiveEntityReadyCandidate(
|
internal readonly record struct LiveEntityReadyCandidate(
|
||||||
LiveEntityRecord Record,
|
LiveEntityRecord Record,
|
||||||
ulong CreateIntegrationVersion);
|
ulong CreateIntegrationVersion,
|
||||||
|
ulong PositionAuthorityVersion,
|
||||||
|
ulong ProjectionMutationVersion,
|
||||||
|
ulong ObjDescAuthorityVersion,
|
||||||
|
LiveEntityProjectionKind ProjectionKind,
|
||||||
|
bool IsSpatiallyProjected,
|
||||||
|
WorldEntity? WorldEntity)
|
||||||
|
{
|
||||||
|
internal static LiveEntityReadyCandidate Capture(LiveEntityRecord record)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
return new(
|
||||||
|
record,
|
||||||
|
record.CreateIntegrationVersion,
|
||||||
|
record.PositionAuthorityVersion,
|
||||||
|
record.ProjectionMutationVersion,
|
||||||
|
record.ObjDescAuthorityVersion,
|
||||||
|
record.ProjectionKind,
|
||||||
|
record.IsSpatiallyProjected,
|
||||||
|
record.WorldEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool IsCurrent(LiveEntityRuntime runtime) =>
|
||||||
|
runtime.IsCurrentCreateIntegration(Record, CreateIntegrationVersion)
|
||||||
|
&& Record.PositionAuthorityVersion == PositionAuthorityVersion
|
||||||
|
&& Record.ProjectionMutationVersion == ProjectionMutationVersion
|
||||||
|
&& Record.ObjDescAuthorityVersion == ObjDescAuthorityVersion
|
||||||
|
&& Record.ProjectionKind == ProjectionKind
|
||||||
|
&& Record.IsSpatiallyProjected == IsSpatiallyProjected
|
||||||
|
&& ReferenceEquals(Record.WorldEntity, WorldEntity);
|
||||||
|
}
|
||||||
|
|
||||||
internal interface ILiveEntityNetworkUpdateSink
|
internal interface ILiveEntityNetworkUpdateSink
|
||||||
{
|
{
|
||||||
|
|
@ -104,7 +142,7 @@ internal sealed class LiveEntityHydrationController
|
||||||
private readonly ClientObjectTable _objects;
|
private readonly ClientObjectTable _objects;
|
||||||
private readonly object _datLock;
|
private readonly object _datLock;
|
||||||
private readonly ILiveEntityProjectionMaterializer _materializer;
|
private readonly ILiveEntityProjectionMaterializer _materializer;
|
||||||
private readonly ILiveEntitySpawnRelationshipSink _relationships;
|
private readonly ILiveEntityRelationshipProjection _relationships;
|
||||||
private readonly ILiveEntityReadyPublisher _ready;
|
private readonly ILiveEntityReadyPublisher _ready;
|
||||||
private readonly ILiveEntityWorldOriginCoordinator _origin;
|
private readonly ILiveEntityWorldOriginCoordinator _origin;
|
||||||
private readonly ILiveEntityNetworkUpdateSink _networkUpdates;
|
private readonly ILiveEntityNetworkUpdateSink _networkUpdates;
|
||||||
|
|
@ -117,7 +155,7 @@ internal sealed class LiveEntityHydrationController
|
||||||
ClientObjectTable objects,
|
ClientObjectTable objects,
|
||||||
object datLock,
|
object datLock,
|
||||||
ILiveEntityProjectionMaterializer materializer,
|
ILiveEntityProjectionMaterializer materializer,
|
||||||
ILiveEntitySpawnRelationshipSink relationships,
|
ILiveEntityRelationshipProjection relationships,
|
||||||
ILiveEntityReadyPublisher ready,
|
ILiveEntityReadyPublisher ready,
|
||||||
ILiveEntityWorldOriginCoordinator origin,
|
ILiveEntityWorldOriginCoordinator origin,
|
||||||
ILiveEntityNetworkUpdateSink networkUpdates,
|
ILiveEntityNetworkUpdateSink networkUpdates,
|
||||||
|
|
@ -139,6 +177,8 @@ internal sealed class LiveEntityHydrationController
|
||||||
_diagnostic = diagnostic;
|
_diagnostic = diagnostic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal event Action<uint>? AppearanceApplied;
|
||||||
|
|
||||||
public void OnCreate(WorldSession.EntitySpawn spawn)
|
public void OnCreate(WorldSession.EntitySpawn spawn)
|
||||||
{
|
{
|
||||||
// DatCollection uses one mutable reader cursor shared with streaming.
|
// DatCollection uses one mutable reader cursor shared with streaming.
|
||||||
|
|
@ -228,6 +268,14 @@ internal sealed class LiveEntityHydrationController
|
||||||
expectedCreateIntegrationVersion:
|
expectedCreateIntegrationVersion:
|
||||||
createIntegrationVersion);
|
createIntegrationVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_runtime.IsCurrentRecord(record)
|
||||||
|
&& record.AppearanceProjectionSynchronizationPending)
|
||||||
|
{
|
||||||
|
SynchronizeAppearance(
|
||||||
|
record,
|
||||||
|
record.ObjDescAuthorityVersion);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception applyFailure)
|
catch (Exception applyFailure)
|
||||||
|
|
@ -248,6 +296,68 @@ internal sealed class LiveEntityHydrationController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies retail <c>SmartBox::UpdateVisualDesc @ 0x00451F40</c> to the
|
||||||
|
/// retained object. Identity, animation owner, physics, effects, and cell
|
||||||
|
/// membership survive; only the DAT-backed visual projection and its
|
||||||
|
/// derived collision payload are replaced.
|
||||||
|
/// </summary>
|
||||||
|
public bool OnAppearance(ObjDescEvent.Parsed update)
|
||||||
|
{
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
if (!_runtime.TryApplyObjDesc(update, out _)
|
||||||
|
|| !_runtime.TryGetRecord(update.Guid, out LiveEntityRecord record))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SynchronizeAppearance(
|
||||||
|
record,
|
||||||
|
record.ObjDescAuthorityVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>DoPickupEvent @ 0x00452240</c>: consume the shared Position
|
||||||
|
/// timestamp, unparent, and leave world without destroying the logical
|
||||||
|
/// object or any incarnation-scoped owner.
|
||||||
|
/// </summary>
|
||||||
|
public bool OnPickup(PickupEvent.Parsed update)
|
||||||
|
{
|
||||||
|
if (!_runtime.TryApplyPickup(update, out _))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
ChildUnparentDisposition disposition =
|
||||||
|
_relationships.OnChildBecameUnparented(update.Guid);
|
||||||
|
bool accepted = disposition is not ChildUnparentDisposition.Superseded;
|
||||||
|
if (accepted)
|
||||||
|
{
|
||||||
|
_diagnostic?.Invoke(
|
||||||
|
$"live: pickup guid=0x{update.Guid:X8} instSeq={update.InstanceSequence} posSeq={update.PositionSequence}");
|
||||||
|
}
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parent events may precede either CreateObject. The relationship owner
|
||||||
|
/// retains wire order and invokes <see cref="TryAcceptParentForProjection"/>
|
||||||
|
/// only when both exact incarnations are addressable.
|
||||||
|
/// </summary>
|
||||||
|
public void OnParent(ParentEvent.Parsed update) =>
|
||||||
|
_relationships.OnParent(update);
|
||||||
|
|
||||||
|
internal bool TryAcceptParentForProjection(ParentEvent.Parsed update) =>
|
||||||
|
_runtime.TryApplyParent(update, out _);
|
||||||
|
|
||||||
|
internal bool OnCreateParentAccepted(CreateParentUpdate update)
|
||||||
|
{
|
||||||
|
if (!_runtime.TryApplyCreateParent(update, out _))
|
||||||
|
return false;
|
||||||
|
_relationships.OnCreateParentAccepted(update);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reprojects retained live objects after their landblock is loaded. An
|
/// Reprojects retained live objects after their landblock is loaded. An
|
||||||
/// fully hydrated record only rebuckets. A retained partial projection
|
/// fully hydrated record only rebuckets. A retained partial projection
|
||||||
|
|
@ -263,7 +373,8 @@ internal sealed class LiveEntityHydrationController
|
||||||
LiveEntityRecord[] records = _runtime.Records
|
LiveEntityRecord[] records = _runtime.Records
|
||||||
.Where(record => (record.ServerGuid != _playerGuid()
|
.Where(record => (record.ServerGuid != _playerGuid()
|
||||||
|| !record.InitialHydrationCompleted
|
|| !record.InitialHydrationCompleted
|
||||||
|| record.CreateProjectionSynchronizationPending)
|
|| record.CreateProjectionSynchronizationPending
|
||||||
|
|| record.AppearanceProjectionSynchronizationPending)
|
||||||
&& record.ProjectionCellId != 0
|
&& record.ProjectionCellId != 0
|
||||||
&& ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical
|
&& ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical
|
||||||
&& record.Snapshot.SetupTableId is not null)
|
&& record.Snapshot.SetupTableId is not null)
|
||||||
|
|
@ -279,6 +390,9 @@ internal sealed class LiveEntityHydrationController
|
||||||
if (!_runtime.IsCurrentRecord(record))
|
if (!_runtime.IsCurrentRecord(record))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
ulong projectedObjDescVersion = record.ObjDescAuthorityVersion;
|
||||||
|
bool projectedCanonicalAppearance = false;
|
||||||
|
|
||||||
if (record.CreateProjectionSynchronizationPending)
|
if (record.CreateProjectionSynchronizationPending)
|
||||||
{
|
{
|
||||||
if (ProjectExact(
|
if (ProjectExact(
|
||||||
|
|
@ -287,6 +401,7 @@ internal sealed class LiveEntityHydrationController
|
||||||
LiveProjectionPurpose.CreateSupersessionRecovery))
|
LiveProjectionPurpose.CreateSupersessionRecovery))
|
||||||
{
|
{
|
||||||
projected++;
|
projected++;
|
||||||
|
projectedCanonicalAppearance = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (record.WorldEntity is not null
|
else if (record.WorldEntity is not null
|
||||||
|
|
@ -303,6 +418,24 @@ internal sealed class LiveEntityHydrationController
|
||||||
record,
|
record,
|
||||||
record.Snapshot,
|
record.Snapshot,
|
||||||
LiveProjectionPurpose.SpatialRecovery))
|
LiveProjectionPurpose.SpatialRecovery))
|
||||||
|
{
|
||||||
|
projected++;
|
||||||
|
projectedCanonicalAppearance = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectedCanonicalAppearance
|
||||||
|
&& record.AppearanceProjectionSynchronizationPending)
|
||||||
|
{
|
||||||
|
CompleteAppearanceProjectionSynchronization(
|
||||||
|
record,
|
||||||
|
projectedObjDescVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_runtime.IsCurrentRecord(record)
|
||||||
|
&& record.AppearanceProjectionSynchronizationPending
|
||||||
|
&& SynchronizeAppearance(
|
||||||
|
record,
|
||||||
|
record.ObjDescAuthorityVersion))
|
||||||
{
|
{
|
||||||
projected++;
|
projected++;
|
||||||
}
|
}
|
||||||
|
|
@ -353,7 +486,8 @@ internal sealed class LiveEntityHydrationController
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProjectExact(
|
ulong projectedObjDescVersion = expectedRecord.ObjDescAuthorityVersion;
|
||||||
|
bool projected = ProjectExact(
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
acceptedSpawn,
|
acceptedSpawn,
|
||||||
expectedRecord.CreateProjectionSynchronizationPending
|
expectedRecord.CreateProjectionSynchronizationPending
|
||||||
|
|
@ -362,49 +496,128 @@ internal sealed class LiveEntityHydrationController
|
||||||
&& _runtime.IsCurrentPositionAuthority(
|
&& _runtime.IsCurrentPositionAuthority(
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
positionAuthorityVersion);
|
positionAuthorityVersion);
|
||||||
|
if (!projected)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (expectedRecord.AppearanceProjectionSynchronizationPending)
|
||||||
|
{
|
||||||
|
if (_runtime.IsCurrentObjDescAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
projectedObjDescVersion))
|
||||||
|
{
|
||||||
|
CompleteAppearanceProjectionSynchronization(
|
||||||
|
expectedRecord,
|
||||||
|
projectedObjDescVersion);
|
||||||
|
}
|
||||||
|
else if (!SynchronizeAppearance(
|
||||||
|
expectedRecord,
|
||||||
|
expectedRecord.ObjDescAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ApplyAppearance(
|
return _runtime.IsCurrentPositionAuthority(
|
||||||
LiveEntityRecord expectedRecord,
|
|
||||||
WorldSession.EntitySpawn acceptedSpawn,
|
|
||||||
LiveEntityAppearanceUpdateState? appearanceUpdate)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
||||||
lock (_datLock)
|
|
||||||
{
|
|
||||||
return ProjectExact(
|
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
acceptedSpawn,
|
positionAuthorityVersion);
|
||||||
appearanceUpdate is null
|
|
||||||
? LiveProjectionPurpose.SpatialRecovery
|
|
||||||
: LiveProjectionPurpose.AppearanceMutation,
|
|
||||||
appearanceUpdate);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
|
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
|
||||||
{
|
{
|
||||||
if (!PublishReady(
|
if (!PublishReady(candidate))
|
||||||
candidate.Record,
|
|
||||||
candidate.CreateIntegrationVersion))
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !candidate.Record.CreateProjectionSynchronizationPending
|
if (candidate.Record.AppearanceProjectionSynchronizationPending
|
||||||
|
&& !candidate.Record.AppearanceHydrationInProgress)
|
||||||
|
{
|
||||||
|
CompleteAppearanceProjectionSynchronization(
|
||||||
|
candidate.Record,
|
||||||
|
candidate.ObjDescAuthorityVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.IsCurrent(_runtime)
|
||||||
|
&& (!candidate.Record.CreateProjectionSynchronizationPending
|
||||||
|| _runtime.TryCompleteCreateProjectionSynchronization(
|
|| _runtime.TryCompleteCreateProjectionSynchronization(
|
||||||
candidate.Record,
|
candidate.Record,
|
||||||
candidate.CreateIntegrationVersion);
|
candidate.CreateIntegrationVersion));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ResetSessionState() => _materializer.ResetSessionState();
|
public void ResetSessionState() => _materializer.ResetSessionState();
|
||||||
|
|
||||||
|
private bool SynchronizeAppearance(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedObjDescAuthorityVersion)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (!_runtime.TryBeginAppearanceHydration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedObjDescAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool retry;
|
||||||
|
bool published;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (expectedRecord.ProjectionKind is
|
||||||
|
LiveEntityProjectionKind.Attached)
|
||||||
|
{
|
||||||
|
published = _relationships.TryApplyAttachedAppearance(
|
||||||
|
expectedRecord,
|
||||||
|
expectedObjDescAuthorityVersion);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LiveEntityAppearanceUpdateState? appearanceState =
|
||||||
|
LiveEntityAppearanceBinding.Capture(
|
||||||
|
_runtime,
|
||||||
|
expectedRecord.ServerGuid);
|
||||||
|
if (appearanceState is null)
|
||||||
|
{
|
||||||
|
published = ProjectExact(
|
||||||
|
expectedRecord,
|
||||||
|
expectedRecord.Snapshot,
|
||||||
|
LiveProjectionPurpose.SpatialRecovery);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
published = _materializer.TryMaterialize(
|
||||||
|
expectedRecord,
|
||||||
|
expectedRecord.Snapshot,
|
||||||
|
LiveProjectionPurpose.AppearanceMutation,
|
||||||
|
expectedRecord.CreateIntegrationVersion,
|
||||||
|
appearanceState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
retry = _runtime.EndAppearanceHydration(expectedRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retry && _runtime.IsCurrentRecord(expectedRecord))
|
||||||
|
{
|
||||||
|
expectedObjDescAuthorityVersion =
|
||||||
|
expectedRecord.ObjDescAuthorityVersion;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return published
|
||||||
|
&& CompleteAppearanceProjectionSynchronization(
|
||||||
|
expectedRecord,
|
||||||
|
expectedObjDescAuthorityVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private bool ProjectExact(
|
private bool ProjectExact(
|
||||||
LiveEntityRecord expectedRecord,
|
LiveEntityRecord expectedRecord,
|
||||||
WorldSession.EntitySpawn acceptedSpawn,
|
WorldSession.EntitySpawn acceptedSpawn,
|
||||||
LiveProjectionPurpose purpose,
|
LiveProjectionPurpose purpose,
|
||||||
LiveEntityAppearanceUpdateState? appearanceUpdate = null,
|
|
||||||
ulong? expectedCreateIntegrationVersion = null)
|
ulong? expectedCreateIntegrationVersion = null)
|
||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
|
|
@ -433,7 +646,6 @@ internal sealed class LiveEntityHydrationController
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
acceptedSpawn,
|
acceptedSpawn,
|
||||||
purpose,
|
purpose,
|
||||||
appearanceUpdate,
|
|
||||||
createIntegrationVersion);
|
createIntegrationVersion);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -449,7 +661,6 @@ internal sealed class LiveEntityHydrationController
|
||||||
// newest canonical snapshot; logical resources remain registered.
|
// newest canonical snapshot; logical resources remain registered.
|
||||||
acceptedSpawn = expectedRecord.Snapshot;
|
acceptedSpawn = expectedRecord.Snapshot;
|
||||||
purpose = LiveProjectionPurpose.CreateSupersessionRecovery;
|
purpose = LiveProjectionPurpose.CreateSupersessionRecovery;
|
||||||
appearanceUpdate = null;
|
|
||||||
expectedCreateIntegrationVersion =
|
expectedCreateIntegrationVersion =
|
||||||
expectedRecord.CreateIntegrationVersion;
|
expectedRecord.CreateIntegrationVersion;
|
||||||
}
|
}
|
||||||
|
|
@ -459,7 +670,6 @@ internal sealed class LiveEntityHydrationController
|
||||||
LiveEntityRecord expectedRecord,
|
LiveEntityRecord expectedRecord,
|
||||||
WorldSession.EntitySpawn acceptedSpawn,
|
WorldSession.EntitySpawn acceptedSpawn,
|
||||||
LiveProjectionPurpose purpose,
|
LiveProjectionPurpose purpose,
|
||||||
LiveEntityAppearanceUpdateState? appearanceUpdate,
|
|
||||||
ulong createIntegrationVersion)
|
ulong createIntegrationVersion)
|
||||||
{
|
{
|
||||||
_relationships.OnSpawn(acceptedSpawn);
|
_relationships.OnSpawn(acceptedSpawn);
|
||||||
|
|
@ -550,8 +760,7 @@ internal sealed class LiveEntityHydrationController
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
expectedRecord.Snapshot,
|
expectedRecord.Snapshot,
|
||||||
purpose,
|
purpose,
|
||||||
createIntegrationVersion,
|
createIntegrationVersion);
|
||||||
appearanceUpdate);
|
|
||||||
if (!materialized
|
if (!materialized
|
||||||
|| !_runtime.IsCurrentCreateIntegration(
|
|| !_runtime.IsCurrentCreateIntegration(
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
|
|
@ -579,22 +788,46 @@ internal sealed class LiveEntityHydrationController
|
||||||
ulong expectedCreateIntegrationVersion)
|
ulong expectedCreateIntegrationVersion)
|
||||||
{
|
{
|
||||||
if (!_runtime.IsCurrentCreateIntegration(
|
if (!_runtime.IsCurrentCreateIntegration(
|
||||||
expectedRecord,
|
|
||||||
expectedCreateIntegrationVersion)
|
|
||||||
|| !_ready.Publish(expectedRecord)
|
|
||||||
|| !_runtime.IsCurrentCreateIntegration(
|
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
expectedCreateIntegrationVersion))
|
expectedCreateIntegrationVersion))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return PublishReady(LiveEntityReadyCandidate.Capture(expectedRecord));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool PublishReady(LiveEntityReadyCandidate candidate)
|
||||||
|
{
|
||||||
|
LiveEntityRecord expectedRecord = candidate.Record;
|
||||||
|
if (!candidate.IsCurrent(_runtime)
|
||||||
|
|| !_ready.Publish(candidate)
|
||||||
|
|| !candidate.IsCurrent(_runtime))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return _runtime.TryMarkInitialHydrationCompleted(
|
return _runtime.TryMarkInitialHydrationCompleted(
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
expectedCreateIntegrationVersion)
|
candidate.CreateIntegrationVersion)
|
||||||
&& _runtime.IsCurrentCreateIntegration(
|
&& candidate.IsCurrent(_runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompleteAppearanceProjectionSynchronization(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedObjDescAuthorityVersion)
|
||||||
|
{
|
||||||
|
if (!_runtime.TryCompleteAppearanceProjectionSynchronization(
|
||||||
expectedRecord,
|
expectedRecord,
|
||||||
expectedCreateIntegrationVersion);
|
expectedObjDescAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppearanceApplied?.Invoke(expectedRecord.ServerGuid);
|
||||||
|
return _runtime.IsCurrentObjDescAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
expectedObjDescAuthorityVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeOriginAndRecoverLoaded(
|
private void InitializeOriginAndRecoverLoaded(
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,48 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
using AcDream.App.Rendering.Vfx;
|
using AcDream.App.Rendering.Vfx;
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
namespace AcDream.App.World;
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
internal sealed class DelegateLiveEntitySpawnRelationshipSink(
|
internal sealed class LiveEntityRelationshipProjection(
|
||||||
Action<WorldSession.EntitySpawn> onSpawn) : ILiveEntitySpawnRelationshipSink
|
EquippedChildRenderController children) : ILiveEntityRelationshipProjection
|
||||||
{
|
{
|
||||||
private readonly Action<WorldSession.EntitySpawn> _onSpawn =
|
private readonly EquippedChildRenderController _children =
|
||||||
onSpawn ?? throw new ArgumentNullException(nameof(onSpawn));
|
children ?? throw new ArgumentNullException(nameof(children));
|
||||||
|
|
||||||
public void OnSpawn(WorldSession.EntitySpawn spawn) => _onSpawn(spawn);
|
public void OnSpawn(WorldSession.EntitySpawn spawn) => _children.OnSpawn(spawn);
|
||||||
|
public void OnParent(ParentEvent.Parsed update) => _children.OnParentEvent(update);
|
||||||
|
public void OnCreateParentAccepted(CreateParentUpdate update) =>
|
||||||
|
_children.OnCreateParentAccepted(update);
|
||||||
|
public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) =>
|
||||||
|
_children.OnChildBecameUnparented(childGuid);
|
||||||
|
public bool TryApplyAttachedAppearance(
|
||||||
|
LiveEntityRecord record,
|
||||||
|
ulong objDescAuthorityVersion) =>
|
||||||
|
_children.TryApplyAttachedAppearance(record, objDescAuthorityVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fail-fast construction seam for the retail ParentEvent timestamp gate.
|
||||||
|
/// The equipped-child renderer can be built before hydration, but no live
|
||||||
|
/// session may resolve a relation until hydration binds this once.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DeferredLiveEntityParentAcceptance
|
||||||
|
{
|
||||||
|
private Func<ParentEvent.Parsed, bool>? _accept;
|
||||||
|
|
||||||
|
public void Bind(Func<ParentEvent.Parsed, bool> accept)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(accept);
|
||||||
|
if (Interlocked.CompareExchange(ref _accept, accept, null) is not null)
|
||||||
|
throw new InvalidOperationException("Live parent acceptance is already bound.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAccept(ParentEvent.Parsed update) =>
|
||||||
|
(_accept ?? throw new InvalidOperationException(
|
||||||
|
"Live parent acceptance must be bound before a session starts."))(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
|
internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
|
||||||
|
|
@ -49,31 +81,23 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
|
||||||
?? throw new ArgumentNullException(nameof(replayEffects));
|
?? throw new ArgumentNullException(nameof(replayEffects));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Publish(LiveEntityRecord expectedRecord)
|
public bool Publish(LiveEntityReadyCandidate candidate)
|
||||||
{
|
{
|
||||||
|
LiveEntityRecord expectedRecord = candidate.Record;
|
||||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
ulong createIntegrationVersion = expectedRecord.CreateIntegrationVersion;
|
if (!candidate.IsCurrent(_runtime)
|
||||||
if (!_runtime.IsCurrentCreateIntegration(
|
|
||||||
expectedRecord,
|
|
||||||
createIntegrationVersion)
|
|
||||||
|| !_prepareEffects(expectedRecord)
|
|| !_prepareEffects(expectedRecord)
|
||||||
|| !_runtime.IsCurrentCreateIntegration(
|
|| !candidate.IsCurrent(_runtime))
|
||||||
expectedRecord,
|
|
||||||
createIntegrationVersion))
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (!_presentState(expectedRecord)
|
if (!_presentState(expectedRecord)
|
||||||
|| !_runtime.IsCurrentCreateIntegration(
|
|| !candidate.IsCurrent(_runtime))
|
||||||
expectedRecord,
|
|
||||||
createIntegrationVersion))
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _replayEffects(expectedRecord)
|
return _replayEffects(expectedRecord)
|
||||||
&& _runtime.IsCurrentCreateIntegration(
|
&& candidate.IsCurrent(_runtime);
|
||||||
expectedRecord,
|
|
||||||
createIntegrationVersion);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ public sealed class LiveEntityRecord
|
||||||
? 0UL
|
? 0UL
|
||||||
: 1UL;
|
: 1UL;
|
||||||
MovementAuthorityVersion = 1UL;
|
MovementAuthorityVersion = 1UL;
|
||||||
|
ObjDescAuthorityVersion = 1UL;
|
||||||
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
|
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
|
||||||
ApplyRawPhysicsState(RawPhysicsState);
|
ApplyRawPhysicsState(RawPhysicsState);
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +259,7 @@ public sealed class LiveEntityRecord
|
||||||
internal ulong VectorAuthorityVersion { get; private set; }
|
internal ulong VectorAuthorityVersion { get; private set; }
|
||||||
internal ulong VelocityAuthorityVersion { get; private set; }
|
internal ulong VelocityAuthorityVersion { get; private set; }
|
||||||
internal ulong MovementAuthorityVersion { get; private set; }
|
internal ulong MovementAuthorityVersion { get; private set; }
|
||||||
|
internal ulong ObjDescAuthorityVersion { get; private set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Advances for every accepted CreateObject affecting this incarnation,
|
/// Advances for every accepted CreateObject affecting this incarnation,
|
||||||
/// including a fresher same-INSTANCE_TS retransmit. App integration uses
|
/// including a fresher same-INSTANCE_TS retransmit. App integration uses
|
||||||
|
|
@ -284,6 +286,15 @@ public sealed class LiveEntityRecord
|
||||||
internal bool ProjectionHydrationInProgress { get; set; }
|
internal bool ProjectionHydrationInProgress { get; set; }
|
||||||
internal ulong ProjectionHydrationCreateVersion { get; set; }
|
internal ulong ProjectionHydrationCreateVersion { get; set; }
|
||||||
internal bool ProjectionHydrationRetryRequested { get; set; }
|
internal bool ProjectionHydrationRetryRequested { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// A renderer mesh transaction can be re-entered by a newer accepted
|
||||||
|
/// ObjDesc. Keep that independent projection obligation on the exact
|
||||||
|
/// incarnation until the newest visual description publishes.
|
||||||
|
/// </summary>
|
||||||
|
internal bool AppearanceProjectionSynchronizationPending { get; set; }
|
||||||
|
internal bool AppearanceHydrationInProgress { get; set; }
|
||||||
|
internal ulong AppearanceHydrationVersion { get; set; }
|
||||||
|
internal bool AppearanceHydrationRetryRequested { get; set; }
|
||||||
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
||||||
internal bool RuntimeComponentsTeardownCompleted { get; set; }
|
internal bool RuntimeComponentsTeardownCompleted { get; set; }
|
||||||
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
|
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
|
||||||
|
|
@ -338,6 +349,14 @@ public sealed class LiveEntityRecord
|
||||||
VelocityAuthorityVersion++;
|
VelocityAuthorityVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void AdvanceObjDescAuthority()
|
||||||
|
{
|
||||||
|
ObjDescAuthorityVersion++;
|
||||||
|
AppearanceProjectionSynchronizationPending = true;
|
||||||
|
if (AppearanceHydrationInProgress)
|
||||||
|
AppearanceHydrationRetryRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
internal void AdvanceCreateAuthority()
|
internal void AdvanceCreateAuthority()
|
||||||
{
|
{
|
||||||
PositionAuthorityVersion++;
|
PositionAuthorityVersion++;
|
||||||
|
|
@ -345,6 +364,7 @@ public sealed class LiveEntityRecord
|
||||||
VectorAuthorityVersion++;
|
VectorAuthorityVersion++;
|
||||||
VelocityAuthorityVersion++;
|
VelocityAuthorityVersion++;
|
||||||
MovementAuthorityVersion++;
|
MovementAuthorityVersion++;
|
||||||
|
ObjDescAuthorityVersion++;
|
||||||
CreateIntegrationVersion++;
|
CreateIntegrationVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1547,7 +1567,12 @@ public sealed class LiveEntityRuntime
|
||||||
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
||||||
{
|
{
|
||||||
bool applied = _inbound.TryApplyObjDesc(update, out accepted);
|
bool applied = _inbound.TryApplyObjDesc(update, out accepted);
|
||||||
if (applied) RefreshRecord(update.Guid, accepted);
|
if (applied)
|
||||||
|
{
|
||||||
|
RefreshRecord(update.Guid, accepted);
|
||||||
|
if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
|
||||||
|
record.AdvanceObjDescAuthority();
|
||||||
|
}
|
||||||
return applied;
|
return applied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1805,6 +1830,13 @@ public sealed class LiveEntityRuntime
|
||||||
&& ReferenceEquals(current, record)
|
&& ReferenceEquals(current, record)
|
||||||
&& current.MovementAuthorityVersion == authorityVersion;
|
&& current.MovementAuthorityVersion == authorityVersion;
|
||||||
|
|
||||||
|
internal bool IsCurrentObjDescAuthority(
|
||||||
|
LiveEntityRecord record,
|
||||||
|
ulong authorityVersion) =>
|
||||||
|
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
|
||||||
|
&& ReferenceEquals(current, record)
|
||||||
|
&& current.ObjDescAuthorityVersion == authorityVersion;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copies the canonical loaded root-object workset used by retail
|
/// Copies the canonical loaded root-object workset used by retail
|
||||||
/// <c>CPhysics::UseTime</c>. The record reference is the incarnation token;
|
/// <c>CPhysics::UseTime</c>. The record reference is the incarnation token;
|
||||||
|
|
@ -2156,6 +2188,63 @@ public sealed class LiveEntityRuntime
|
||||||
return retry;
|
return retry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool TryBeginAppearanceHydration(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedObjDescAuthorityVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
if (!expectedRecord.AppearanceProjectionSynchronizationPending
|
||||||
|
|| !IsCurrentObjDescAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
expectedObjDescAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRecord.AppearanceProjectionSynchronizationPending = true;
|
||||||
|
if (expectedRecord.AppearanceHydrationInProgress)
|
||||||
|
{
|
||||||
|
expectedRecord.AppearanceHydrationRetryRequested = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRecord.AppearanceHydrationInProgress = true;
|
||||||
|
expectedRecord.AppearanceHydrationVersion =
|
||||||
|
expectedObjDescAuthorityVersion;
|
||||||
|
expectedRecord.AppearanceHydrationRetryRequested = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool EndAppearanceHydration(LiveEntityRecord expectedRecord)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
bool retry = IsCurrentRecord(expectedRecord)
|
||||||
|
&& (expectedRecord.AppearanceHydrationRetryRequested
|
||||||
|
|| expectedRecord.ObjDescAuthorityVersion
|
||||||
|
!= expectedRecord.AppearanceHydrationVersion);
|
||||||
|
expectedRecord.AppearanceHydrationInProgress = false;
|
||||||
|
expectedRecord.AppearanceHydrationVersion = 0UL;
|
||||||
|
expectedRecord.AppearanceHydrationRetryRequested = false;
|
||||||
|
return retry;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryCompleteAppearanceProjectionSynchronization(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedObjDescAuthorityVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
if (!expectedRecord.AppearanceProjectionSynchronizationPending
|
||||||
|
|| !IsCurrentObjDescAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
expectedObjDescAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRecord.AppearanceProjectionSynchronizationPending = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
internal bool IsCurrentCreateIntegration(
|
internal bool IsCurrentCreateIntegration(
|
||||||
LiveEntityRecord expectedRecord,
|
LiveEntityRecord expectedRecord,
|
||||||
ulong expectedCreateIntegrationVersion) =>
|
ulong expectedCreateIntegrationVersion) =>
|
||||||
|
|
@ -2722,6 +2811,10 @@ public sealed class LiveEntityRuntime
|
||||||
record.ProjectionHydrationInProgress = false;
|
record.ProjectionHydrationInProgress = false;
|
||||||
record.ProjectionHydrationCreateVersion = 0UL;
|
record.ProjectionHydrationCreateVersion = 0UL;
|
||||||
record.ProjectionHydrationRetryRequested = false;
|
record.ProjectionHydrationRetryRequested = false;
|
||||||
|
record.AppearanceProjectionSynchronizationPending = false;
|
||||||
|
record.AppearanceHydrationInProgress = false;
|
||||||
|
record.AppearanceHydrationVersion = 0UL;
|
||||||
|
record.AppearanceHydrationRetryRequested = false;
|
||||||
record.WorldEntity = null;
|
record.WorldEntity = null;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,134 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
||||||
Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
|
Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpawnParentWithoutAnimationFrame_UsesRetailPlacementZero()
|
||||||
|
{
|
||||||
|
using var fixture = new ControllerFixture((_, _, _) =>
|
||||||
|
new ExactProjectionWithdrawalOutcome(
|
||||||
|
ExactProjectionWithdrawalDisposition.Completed,
|
||||||
|
Failure: null));
|
||||||
|
LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1);
|
||||||
|
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
|
||||||
|
0x70000271u,
|
||||||
|
generation: 1) with
|
||||||
|
{
|
||||||
|
Position = null,
|
||||||
|
ParentGuid = parent.ServerGuid,
|
||||||
|
ParentLocation = 0,
|
||||||
|
PlacementId = null,
|
||||||
|
PositionSequence = 0,
|
||||||
|
};
|
||||||
|
LiveEntityRecord child = fixture.Live.RegisterLiveEntity(childSpawn).Record!;
|
||||||
|
|
||||||
|
fixture.Controller.OnSpawn(childSpawn);
|
||||||
|
|
||||||
|
var expected = new ParentAttachmentRelation(
|
||||||
|
parent.ServerGuid,
|
||||||
|
child.ServerGuid,
|
||||||
|
ParentLocation: 0,
|
||||||
|
PlacementId: 0,
|
||||||
|
ParentInstanceSequence: 0,
|
||||||
|
ChildPositionSequence: 0);
|
||||||
|
Assert.True(fixture.Live.ParentAttachments.IsCommitted(expected));
|
||||||
|
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
|
||||||
|
fixture.Controller.OnPosePublished(parent.ServerGuid);
|
||||||
|
Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
|
||||||
|
Assert.NotNull(child.WorldEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObjDesc_ReprojectsAttachedChildWithoutWorldPositionOrIdentityChange()
|
||||||
|
{
|
||||||
|
ControllerFixture? fixture = null;
|
||||||
|
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
||||||
|
{
|
||||||
|
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
||||||
|
record,
|
||||||
|
positionVersion,
|
||||||
|
projectionVersion);
|
||||||
|
return new(
|
||||||
|
completed
|
||||||
|
? ExactProjectionWithdrawalDisposition.Completed
|
||||||
|
: ExactProjectionWithdrawalDisposition.Superseded,
|
||||||
|
Failure: null);
|
||||||
|
});
|
||||||
|
using (fixture)
|
||||||
|
{
|
||||||
|
LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1);
|
||||||
|
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
|
||||||
|
0x70000273u,
|
||||||
|
generation: 1) with
|
||||||
|
{
|
||||||
|
Position = null,
|
||||||
|
ParentGuid = parent.ServerGuid,
|
||||||
|
ParentLocation = 0,
|
||||||
|
PlacementId = 0,
|
||||||
|
PositionSequence = 0,
|
||||||
|
};
|
||||||
|
LiveEntityRecord child = fixture.Live.RegisterLiveEntity(childSpawn).Record!;
|
||||||
|
fixture.Controller.OnSpawn(childSpawn);
|
||||||
|
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
|
||||||
|
fixture.Controller.OnPosePublished(parent.ServerGuid);
|
||||||
|
WorldEntity entity = child.WorldEntity!;
|
||||||
|
Assert.Null(entity.PaletteOverride);
|
||||||
|
WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData(
|
||||||
|
0x70000274u,
|
||||||
|
generation: 1) with
|
||||||
|
{
|
||||||
|
Position = null,
|
||||||
|
ParentGuid = child.ServerGuid,
|
||||||
|
ParentLocation = 0,
|
||||||
|
PlacementId = 0,
|
||||||
|
PositionSequence = 0,
|
||||||
|
};
|
||||||
|
LiveEntityRecord grandchild = fixture.Live.RegisterLiveEntity(
|
||||||
|
grandchildSpawn).Record!;
|
||||||
|
fixture.Controller.OnSpawn(grandchildSpawn);
|
||||||
|
WorldEntity grandchildEntity = grandchild.WorldEntity!;
|
||||||
|
Assert.Equal(
|
||||||
|
LiveEntityProjectionKind.Attached,
|
||||||
|
grandchild.ProjectionKind);
|
||||||
|
|
||||||
|
Assert.True(fixture.Live.TryApplyObjDesc(
|
||||||
|
new ObjDescEvent.Parsed(
|
||||||
|
child.ServerGuid,
|
||||||
|
new CreateObject.ModelData(
|
||||||
|
BasePaletteId: 0x04000022u,
|
||||||
|
[new CreateObject.SubPaletteSwap(
|
||||||
|
0x0F000033u,
|
||||||
|
Offset: 4,
|
||||||
|
Length: 8)],
|
||||||
|
Array.Empty<CreateObject.TextureChange>(),
|
||||||
|
Array.Empty<CreateObject.AnimPartChange>()),
|
||||||
|
InstanceSequence: 1,
|
||||||
|
ObjDescSequence: 1),
|
||||||
|
out _));
|
||||||
|
Assert.Null(child.Snapshot.Position);
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.TryApplyAttachedAppearance(
|
||||||
|
child,
|
||||||
|
child.ObjDescAuthorityVersion));
|
||||||
|
|
||||||
|
Assert.Same(entity, child.WorldEntity);
|
||||||
|
Assert.True(child.IsSpatiallyProjected);
|
||||||
|
Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
|
||||||
|
Assert.Equal((uint)0x04000022u, child.Snapshot.BasePaletteId);
|
||||||
|
Assert.NotNull(entity.PaletteOverride);
|
||||||
|
Assert.Equal(0x04000022u, entity.PaletteOverride!.BasePaletteId);
|
||||||
|
PaletteOverride.SubPaletteRange range =
|
||||||
|
Assert.Single(entity.PaletteOverride.SubPalettes);
|
||||||
|
Assert.Equal(0x0F000033u, range.SubPaletteId);
|
||||||
|
Assert.Equal((byte)4, range.Offset);
|
||||||
|
Assert.Equal((byte)8, range.Length);
|
||||||
|
Assert.Same(grandchildEntity, grandchild.WorldEntity);
|
||||||
|
Assert.True(grandchild.IsSpatiallyProjected);
|
||||||
|
Assert.Equal(
|
||||||
|
LiveEntityProjectionKind.Attached,
|
||||||
|
grandchild.ProjectionKind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ChildWithoutSetup_StillCommitsLogicalParenting()
|
public void ChildWithoutSetup_StillCommitsLogicalParenting()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ public sealed class GameWindowLiveEntityCompositionTests
|
||||||
[InlineData("RehydrateServerEntitiesForLandblock")]
|
[InlineData("RehydrateServerEntitiesForLandblock")]
|
||||||
[InlineData("TryInitializeLiveCenter")]
|
[InlineData("TryInitializeLiveCenter")]
|
||||||
[InlineData("CompleteLiveEntityReady")]
|
[InlineData("CompleteLiveEntityReady")]
|
||||||
|
[InlineData("OnLiveAppearanceUpdated")]
|
||||||
|
[InlineData("OnLiveEntityPickedUp")]
|
||||||
|
[InlineData("OnLiveParentUpdated")]
|
||||||
|
[InlineData("TryAcceptParentForRender")]
|
||||||
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
|
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
|
||||||
{
|
{
|
||||||
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
|
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,25 @@ namespace AcDream.App.Tests.World;
|
||||||
|
|
||||||
public sealed class LiveEntityHydrationControllerTests
|
public sealed class LiveEntityHydrationControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParentAcceptanceBridge_IsFailFastAndSingleAssignment()
|
||||||
|
{
|
||||||
|
var bridge = new DeferredLiveEntityParentAcceptance();
|
||||||
|
var update = new ParentEvent.Parsed(1, 2, 3, 4, 5, 6);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => bridge.TryAccept(update));
|
||||||
|
|
||||||
|
ParentEvent.Parsed accepted = default;
|
||||||
|
bridge.Bind(value =>
|
||||||
|
{
|
||||||
|
accepted = value;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(bridge.TryAccept(update));
|
||||||
|
Assert.Equal(update, accepted);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => bridge.Bind(_ => false));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization()
|
public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization()
|
||||||
{
|
{
|
||||||
|
|
@ -93,10 +112,7 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
LiveEntityRecord record = fixture.Record;
|
LiveEntityRecord record = fixture.Record;
|
||||||
Assert.Null(record.WorldEntity);
|
Assert.Null(record.WorldEntity);
|
||||||
|
|
||||||
Assert.True(fixture.Controller.ApplyAppearance(
|
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
|
||||||
record,
|
|
||||||
record.Snapshot,
|
|
||||||
appearanceUpdate: null));
|
|
||||||
|
|
||||||
Assert.NotNull(record.WorldEntity);
|
Assert.NotNull(record.WorldEntity);
|
||||||
Assert.Equal(2, fixture.Materializer.Calls.Count);
|
Assert.Equal(2, fixture.Materializer.Calls.Count);
|
||||||
|
|
@ -105,6 +121,324 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
Assert.Equal(1, fixture.Ready.PublishCount);
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceUpdate_ReplacesOnlyProjectionAndPreservesExactOwners()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
LiveEntityRecord record = fixture.Record;
|
||||||
|
WorldEntity entity = record.WorldEntity!;
|
||||||
|
int applied = 0;
|
||||||
|
fixture.Controller.AppearanceApplied += _ => applied++;
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
|
||||||
|
|
||||||
|
Assert.Same(record, fixture.Record);
|
||||||
|
Assert.Same(entity, record.WorldEntity);
|
||||||
|
Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId);
|
||||||
|
Assert.False(record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Equal(LiveProjectionPurpose.AppearanceMutation, fixture.Materializer.Calls[^1].Purpose);
|
||||||
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||||
|
Assert.Equal(0, fixture.Resources.UnregisterCount);
|
||||||
|
Assert.Equal(1, applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NestedAppearanceUpdate_ReplaysNewestCanonicalSnapshotAfterOuterPublication()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
int applied = 0;
|
||||||
|
fixture.Controller.AppearanceApplied += _ => applied++;
|
||||||
|
bool nested = false;
|
||||||
|
bool nestedResult = true;
|
||||||
|
fixture.Materializer.BeforeMaterialize = (_, _) =>
|
||||||
|
{
|
||||||
|
if (fixture.Materializer.Calls[^1].Purpose is not LiveProjectionPurpose.AppearanceMutation
|
||||||
|
|| nested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nested = true;
|
||||||
|
nestedResult = fixture.Controller.OnAppearance(ObjDesc(3, 0x04000033u));
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
|
||||||
|
|
||||||
|
Assert.False(nestedResult);
|
||||||
|
Assert.Equal((uint)0x04000033u, fixture.Record.Snapshot.BasePaletteId);
|
||||||
|
Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Equal(
|
||||||
|
2,
|
||||||
|
fixture.Materializer.Calls.Count(call =>
|
||||||
|
call.Purpose is LiveProjectionPurpose.AppearanceMutation));
|
||||||
|
Assert.Equal(1, applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearancePublicationFailure_RetainsObligationForLandblockRetry()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
int applied = 0;
|
||||||
|
fixture.Controller.AppearanceApplied += _ => applied++;
|
||||||
|
fixture.Materializer.ThrowAfterMaterializePurposeOnce =
|
||||||
|
LiveProjectionPurpose.AppearanceMutation;
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
|
||||||
|
Assert.True(fixture.Record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Equal(0, applied);
|
||||||
|
|
||||||
|
fixture.Controller.OnLandblockLoaded(Cell);
|
||||||
|
|
||||||
|
Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Equal((uint)0x04000022u, fixture.Record.Snapshot.BasePaletteId);
|
||||||
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||||
|
Assert.Equal(1, applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttachedAppearance_UsesRelationshipProjectionWithoutWorldPosition()
|
||||||
|
{
|
||||||
|
const uint parentGuid = 0x70000002u;
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
LiveEntityRecord record = fixture.Record;
|
||||||
|
WorldEntity entity = record.WorldEntity!;
|
||||||
|
Assert.True(fixture.Runtime.TryApplyCreateParent(
|
||||||
|
new CreateParentUpdate(Guid, parentGuid, 1, 0, 1, 2),
|
||||||
|
out _));
|
||||||
|
Assert.True(fixture.Runtime.CommitStagedParent(
|
||||||
|
new ParentAttachmentRelation(
|
||||||
|
parentGuid,
|
||||||
|
Guid,
|
||||||
|
ParentLocation: 1,
|
||||||
|
PlacementId: 0,
|
||||||
|
ParentInstanceSequence: 0,
|
||||||
|
ChildPositionSequence: 2),
|
||||||
|
out _));
|
||||||
|
record.ProjectionKind = LiveEntityProjectionKind.Attached;
|
||||||
|
record.FullCellId = 0u;
|
||||||
|
int materializerCalls = fixture.Materializer.Calls.Count;
|
||||||
|
int relationshipCalls = 0;
|
||||||
|
int applied = 0;
|
||||||
|
fixture.Relationships.ApplyAttachedAppearance = (expected, version) =>
|
||||||
|
{
|
||||||
|
relationshipCalls++;
|
||||||
|
Assert.Same(record, expected);
|
||||||
|
Assert.True(fixture.Runtime.IsCurrentObjDescAuthority(expected, version));
|
||||||
|
Assert.Null(expected.Snapshot.Position);
|
||||||
|
Assert.Equal((uint)0x04000022u, expected.Snapshot.BasePaletteId);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
fixture.Controller.AppearanceApplied += _ => applied++;
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
|
||||||
|
|
||||||
|
Assert.Same(entity, record.WorldEntity);
|
||||||
|
Assert.Equal(LiveEntityProjectionKind.Attached, record.ProjectionKind);
|
||||||
|
Assert.False(record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Equal(materializerCalls, fixture.Materializer.Calls.Count);
|
||||||
|
Assert.Equal(1, relationshipCalls);
|
||||||
|
Assert.Equal(1, applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(0)]
|
||||||
|
public void StaleOrEqualAppearance_HasNoProjectionOrNotificationSideEffects(
|
||||||
|
ushort sequence)
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
int calls = fixture.Materializer.Calls.Count;
|
||||||
|
int applied = 0;
|
||||||
|
fixture.Controller.AppearanceApplied += _ => applied++;
|
||||||
|
|
||||||
|
Assert.False(fixture.Controller.OnAppearance(
|
||||||
|
ObjDesc(sequence, 0x04000022u)));
|
||||||
|
|
||||||
|
Assert.Equal(calls, fixture.Materializer.Calls.Count);
|
||||||
|
Assert.Equal(0, applied);
|
||||||
|
Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Null(fixture.Record.Snapshot.BasePaletteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppearanceSequenceWrap_PublishesExactlyOnce()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1);
|
||||||
|
spawn = spawn with
|
||||||
|
{
|
||||||
|
Physics = spawn.Physics!.Value with
|
||||||
|
{
|
||||||
|
Timestamps = spawn.Physics.Value.Timestamps with
|
||||||
|
{
|
||||||
|
ObjDesc = 0xFFFF,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fixture.Controller.OnCreate(spawn);
|
||||||
|
int applied = 0;
|
||||||
|
fixture.Controller.AppearanceApplied += _ => applied++;
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.OnAppearance(ObjDesc(0, 0x04000022u)));
|
||||||
|
|
||||||
|
Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Equal((uint)0x04000022u, fixture.Record.Snapshot.BasePaletteId);
|
||||||
|
Assert.Equal(1, applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailedAppearance_IsClearedAtGenerationReplacementAndCannotLeak()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
LiveEntityRecord retired = fixture.Record;
|
||||||
|
fixture.Materializer.SkipMaterializationCount = 1;
|
||||||
|
Assert.False(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
|
||||||
|
Assert.True(retired.AppearanceProjectionSynchronizationPending);
|
||||||
|
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
|
||||||
|
LiveEntityRecord replacement = fixture.Record;
|
||||||
|
fixture.Controller.OnLandblockLoaded(Cell);
|
||||||
|
|
||||||
|
Assert.NotSame(retired, replacement);
|
||||||
|
Assert.False(retired.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.False(retired.AppearanceHydrationInProgress);
|
||||||
|
Assert.False(replacement.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Null(replacement.Snapshot.BasePaletteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pickup_LeavesWorldWithoutEndingLogicalOrResourceIdentity()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
LiveEntityRecord record = fixture.Record;
|
||||||
|
WorldEntity entity = record.WorldEntity!;
|
||||||
|
fixture.Relationships.OnUnparentAction = _ =>
|
||||||
|
fixture.Runtime.WithdrawLiveEntityProjection(record)
|
||||||
|
? ChildUnparentDisposition.Completed
|
||||||
|
: ChildUnparentDisposition.Superseded;
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.OnPickup(
|
||||||
|
new PickupEvent.Parsed(Guid, 1, 2)));
|
||||||
|
|
||||||
|
Assert.Same(record, fixture.Record);
|
||||||
|
Assert.Same(entity, record.WorldEntity);
|
||||||
|
Assert.Equal(0u, record.FullCellId);
|
||||||
|
Assert.False(record.IsSpatiallyProjected);
|
||||||
|
Assert.True(record.ResourcesRegistered);
|
||||||
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||||
|
Assert.Equal(0, fixture.Resources.UnregisterCount);
|
||||||
|
Assert.Contains($"unparent:{Guid:X8}", fixture.Operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PositionAfterPickup_ReentersWithSameEntityBodyAndResources()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
LiveEntityRecord record = fixture.Record;
|
||||||
|
WorldEntity entity = record.WorldEntity!;
|
||||||
|
var body = new PhysicsBody();
|
||||||
|
record.PhysicsBody = body;
|
||||||
|
fixture.Relationships.OnUnparentAction = _ =>
|
||||||
|
fixture.Runtime.WithdrawLiveEntityProjection(record)
|
||||||
|
? ChildUnparentDisposition.Completed
|
||||||
|
: ChildUnparentDisposition.Superseded;
|
||||||
|
Assert.True(fixture.Controller.OnPickup(
|
||||||
|
new PickupEvent.Parsed(Guid, 1, 2)));
|
||||||
|
int applied = 0;
|
||||||
|
fixture.Controller.AppearanceApplied += _ => applied++;
|
||||||
|
Assert.False(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
|
||||||
|
Assert.True(record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Equal(0, applied);
|
||||||
|
|
||||||
|
Assert.True(fixture.Runtime.TryApplyPosition(
|
||||||
|
new WorldSession.EntityPositionUpdate(
|
||||||
|
Guid,
|
||||||
|
new CreateObject.ServerPosition(
|
||||||
|
Cell, 14f, 12f, 6f, 1f, 0f, 0f, 0f),
|
||||||
|
Velocity: null,
|
||||||
|
PlacementId: 0,
|
||||||
|
IsGrounded: true,
|
||||||
|
InstanceSequence: 1,
|
||||||
|
PositionSequence: 3,
|
||||||
|
TeleportSequence: 0,
|
||||||
|
ForcePositionSequence: 0),
|
||||||
|
isLocalPlayer: false,
|
||||||
|
forcePositionRotation: null,
|
||||||
|
currentLocalVelocity: null,
|
||||||
|
out _,
|
||||||
|
out WorldSession.EntitySpawn accepted,
|
||||||
|
out _));
|
||||||
|
ulong positionAuthority = record.PositionAuthorityVersion;
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.RecoverProjection(
|
||||||
|
record,
|
||||||
|
positionAuthority,
|
||||||
|
accepted));
|
||||||
|
|
||||||
|
Assert.Same(entity, record.WorldEntity);
|
||||||
|
Assert.Same(body, record.PhysicsBody);
|
||||||
|
Assert.True(record.IsSpatiallyProjected);
|
||||||
|
Assert.False(record.AppearanceProjectionSynchronizationPending);
|
||||||
|
Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId);
|
||||||
|
Assert.Equal(1, applied);
|
||||||
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||||
|
Assert.Equal(0, fixture.Resources.UnregisterCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParentEvent_IsRetainedByRelationshipOwnerAndAcceptedThroughHydrationGate()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
const uint parentGuid = 0x70000002u;
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
fixture.Runtime.RegisterLiveEntity(
|
||||||
|
Spawn(Generation: 4, PositionSequence: 1) with { Guid = parentGuid });
|
||||||
|
bool accepted = false;
|
||||||
|
fixture.Relationships.OnParentAction = update =>
|
||||||
|
accepted = fixture.Controller.TryAcceptParentForProjection(update);
|
||||||
|
var update = new ParentEvent.Parsed(
|
||||||
|
parentGuid,
|
||||||
|
Guid,
|
||||||
|
ParentLocation: 1,
|
||||||
|
PlacementId: 0,
|
||||||
|
ParentInstanceSequence: 4,
|
||||||
|
ChildPositionSequence: 2);
|
||||||
|
|
||||||
|
fixture.Controller.OnParent(update);
|
||||||
|
|
||||||
|
Assert.True(accepted);
|
||||||
|
Assert.Equal((ushort)2, fixture.Record.Snapshot.PositionSequence);
|
||||||
|
Assert.Contains($"parent:{Guid:X8}", fixture.Operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameGenerationCreateParent_AdvancesSharedPositionAndPublishesRelation()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
var update = new CreateParentUpdate(
|
||||||
|
Guid,
|
||||||
|
ParentGuid: 0x70000002u,
|
||||||
|
ParentLocation: 3,
|
||||||
|
PlacementId: 0,
|
||||||
|
ChildInstanceSequence: 1,
|
||||||
|
ChildPositionSequence: 2);
|
||||||
|
|
||||||
|
Assert.True(fixture.Controller.OnCreateParentAccepted(update));
|
||||||
|
|
||||||
|
Assert.Equal((ushort)2, fixture.Record.Snapshot.PositionSequence);
|
||||||
|
Assert.Contains($"create-parent:{Guid:X8}", fixture.Operations);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce()
|
public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce()
|
||||||
{
|
{
|
||||||
|
|
@ -805,9 +1139,7 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
LiveEntityProjectionKind.Attached);
|
LiveEntityProjectionKind.Attached);
|
||||||
Assert.Same(retained, attached);
|
Assert.Same(retained, attached);
|
||||||
fixture.Controller.OnEntityReady(
|
fixture.Controller.OnEntityReady(
|
||||||
new LiveEntityReadyCandidate(
|
LiveEntityReadyCandidate.Capture(record));
|
||||||
record,
|
|
||||||
record.CreateIntegrationVersion));
|
|
||||||
};
|
};
|
||||||
bool refreshed = false;
|
bool refreshed = false;
|
||||||
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
||||||
|
|
@ -880,9 +1212,8 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
},
|
},
|
||||||
LiveEntityProjectionKind.Attached);
|
LiveEntityProjectionKind.Attached);
|
||||||
Assert.NotNull(attached);
|
Assert.NotNull(attached);
|
||||||
fixture.Controller.OnEntityReady(new LiveEntityReadyCandidate(
|
fixture.Controller.OnEntityReady(
|
||||||
record,
|
LiveEntityReadyCandidate.Capture(record));
|
||||||
record.CreateIntegrationVersion));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fixture.Controller.OnCreate(CelllessSpawn(
|
fixture.Controller.OnCreate(CelllessSpawn(
|
||||||
|
|
@ -945,7 +1276,8 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
_ => { present++; return Stage(1); },
|
_ => { present++; return Stage(1); },
|
||||||
_ => { replay++; return Stage(2); });
|
_ => { replay++; return Stage(2); });
|
||||||
|
|
||||||
Assert.False(publisher.Publish(expected));
|
Assert.False(publisher.Publish(
|
||||||
|
LiveEntityReadyCandidate.Capture(expected)));
|
||||||
Assert.Equal(expectedPrepare, prepare);
|
Assert.Equal(expectedPrepare, prepare);
|
||||||
Assert.Equal(expectedPresent, present);
|
Assert.Equal(expectedPresent, present);
|
||||||
Assert.Equal(expectedReplay, replay);
|
Assert.Equal(expectedReplay, replay);
|
||||||
|
|
@ -953,6 +1285,52 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion);
|
Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 1, 0, 0)]
|
||||||
|
[InlineData(1, 1, 1, 0)]
|
||||||
|
[InlineData(2, 1, 1, 1)]
|
||||||
|
public void ReadyPublisher_RejectsSameIdentityProjectionChangeBetweenStages(
|
||||||
|
int withdrawalStage,
|
||||||
|
int expectedPrepare,
|
||||||
|
int expectedPresent,
|
||||||
|
int expectedReplay)
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
LiveEntityRecord expected = fixture.Record;
|
||||||
|
LiveEntityReadyCandidate candidate =
|
||||||
|
LiveEntityReadyCandidate.Capture(expected);
|
||||||
|
int prepare = 0;
|
||||||
|
int present = 0;
|
||||||
|
int replay = 0;
|
||||||
|
bool withdrawn = false;
|
||||||
|
bool Stage(int stage)
|
||||||
|
{
|
||||||
|
if (!withdrawn && withdrawalStage == stage)
|
||||||
|
{
|
||||||
|
withdrawn = true;
|
||||||
|
Assert.True(fixture.Runtime.TryApplyPickup(
|
||||||
|
new PickupEvent.Parsed(Guid, 1, 2),
|
||||||
|
out _));
|
||||||
|
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(expected));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var publisher = new LiveEntityReadyPublisher(
|
||||||
|
fixture.Runtime,
|
||||||
|
_ => { prepare++; return Stage(0); },
|
||||||
|
_ => { present++; return Stage(1); },
|
||||||
|
_ => { replay++; return Stage(2); });
|
||||||
|
|
||||||
|
Assert.False(publisher.Publish(candidate));
|
||||||
|
Assert.Equal(expectedPrepare, prepare);
|
||||||
|
Assert.Equal(expectedPresent, present);
|
||||||
|
Assert.Equal(expectedReplay, replay);
|
||||||
|
Assert.Same(expected, fixture.Record);
|
||||||
|
Assert.False(expected.IsSpatiallyProjected);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback()
|
public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback()
|
||||||
{
|
{
|
||||||
|
|
@ -970,9 +1348,35 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
|
|
||||||
Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
|
Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
|
||||||
fixture.Runtime,
|
fixture.Runtime,
|
||||||
record,
|
LiveEntityReadyCandidate.Capture(record) with
|
||||||
capturedCreateIntegrationVersion,
|
{
|
||||||
entity,
|
CreateIntegrationVersion = capturedCreateIntegrationVersion,
|
||||||
|
WorldEntity = entity,
|
||||||
|
},
|
||||||
|
_ => published = true));
|
||||||
|
Assert.False(published);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EquippedChildReadyCandidate_RejectsPickupWithdrawalByPoseCallback()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(originKnown: true);
|
||||||
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||||
|
LiveEntityRecord record = fixture.Record;
|
||||||
|
LiveEntityReadyCandidate candidate =
|
||||||
|
LiveEntityReadyCandidate.Capture(record);
|
||||||
|
|
||||||
|
// Models ProjectionPoseReady accepting Pickup and withdrawing the
|
||||||
|
// retained projection before EntityReady is emitted.
|
||||||
|
Assert.True(fixture.Runtime.TryApplyPickup(
|
||||||
|
new PickupEvent.Parsed(Guid, 1, 2),
|
||||||
|
out _));
|
||||||
|
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record));
|
||||||
|
bool published = false;
|
||||||
|
|
||||||
|
Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
|
||||||
|
fixture.Runtime,
|
||||||
|
candidate,
|
||||||
_ => published = true));
|
_ => published = true));
|
||||||
Assert.False(published);
|
Assert.False(published);
|
||||||
}
|
}
|
||||||
|
|
@ -1256,14 +1660,46 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RecordingRelationships(List<string> operations)
|
private sealed class RecordingRelationships(List<string> operations)
|
||||||
: ILiveEntitySpawnRelationshipSink
|
: ILiveEntityRelationshipProjection
|
||||||
{
|
{
|
||||||
public Action<WorldSession.EntitySpawn>? OnSpawnAction { get; set; }
|
public Action<WorldSession.EntitySpawn>? OnSpawnAction { get; set; }
|
||||||
|
public Action<ParentEvent.Parsed>? OnParentAction { get; set; }
|
||||||
|
public Action<CreateParentUpdate>? OnCreateParentAction { get; set; }
|
||||||
|
public Func<uint, ChildUnparentDisposition>? OnUnparentAction { get; set; }
|
||||||
|
public Func<LiveEntityRecord, ulong, bool>? ApplyAttachedAppearance { get; set; }
|
||||||
public void OnSpawn(WorldSession.EntitySpawn spawn)
|
public void OnSpawn(WorldSession.EntitySpawn spawn)
|
||||||
{
|
{
|
||||||
operations.Add($"relationship:{spawn.InstanceSequence}");
|
operations.Add($"relationship:{spawn.InstanceSequence}");
|
||||||
OnSpawnAction?.Invoke(spawn);
|
OnSpawnAction?.Invoke(spawn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnParent(ParentEvent.Parsed update)
|
||||||
|
{
|
||||||
|
operations.Add($"parent:{update.ChildGuid:X8}");
|
||||||
|
OnParentAction?.Invoke(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnCreateParentAccepted(CreateParentUpdate update)
|
||||||
|
{
|
||||||
|
operations.Add($"create-parent:{update.ChildGuid:X8}");
|
||||||
|
OnCreateParentAction?.Invoke(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid)
|
||||||
|
{
|
||||||
|
operations.Add($"unparent:{childGuid:X8}");
|
||||||
|
return OnUnparentAction?.Invoke(childGuid)
|
||||||
|
?? ChildUnparentDisposition.Completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryApplyAttachedAppearance(
|
||||||
|
LiveEntityRecord record,
|
||||||
|
ulong objDescAuthorityVersion)
|
||||||
|
{
|
||||||
|
operations.Add($"attached-appearance:{record.ServerGuid:X8}");
|
||||||
|
return ApplyAttachedAppearance?.Invoke(record, objDescAuthorityVersion)
|
||||||
|
?? false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RecordingReadyPublisher(List<string> operations)
|
private sealed class RecordingReadyPublisher(List<string> operations)
|
||||||
|
|
@ -1272,8 +1708,9 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
public int PublishCount { get; private set; }
|
public int PublishCount { get; private set; }
|
||||||
public int FailPublishCount { get; set; }
|
public int FailPublishCount { get; set; }
|
||||||
public Action<LiveEntityRecord>? BeforePublish { get; set; }
|
public Action<LiveEntityRecord>? BeforePublish { get; set; }
|
||||||
public bool Publish(LiveEntityRecord expectedRecord)
|
public bool Publish(LiveEntityReadyCandidate candidate)
|
||||||
{
|
{
|
||||||
|
LiveEntityRecord expectedRecord = candidate.Record;
|
||||||
PublishCount++;
|
PublishCount++;
|
||||||
operations.Add("ready");
|
operations.Add("ready");
|
||||||
BeforePublish?.Invoke(expectedRecord);
|
BeforePublish?.Invoke(expectedRecord);
|
||||||
|
|
@ -1357,6 +1794,19 @@ public sealed class LiveEntityHydrationControllerTests
|
||||||
Physics: physics);
|
Physics: physics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ObjDescEvent.Parsed ObjDesc(
|
||||||
|
ushort sequence,
|
||||||
|
uint basePaletteId) =>
|
||||||
|
new(
|
||||||
|
Guid,
|
||||||
|
new CreateObject.ModelData(
|
||||||
|
basePaletteId,
|
||||||
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||||
|
Array.Empty<CreateObject.TextureChange>(),
|
||||||
|
Array.Empty<CreateObject.AnimPartChange>()),
|
||||||
|
InstanceSequence: 1,
|
||||||
|
ObjDescSequence: sequence);
|
||||||
|
|
||||||
private static WorldSession.EntitySpawn CelllessSpawn(
|
private static WorldSession.EntitySpawn CelllessSpawn(
|
||||||
ushort PositionSequence,
|
ushort PositionSequence,
|
||||||
uint? parentGuid)
|
uint? parentGuid)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue